sharp is fast and good, but API is so inconvenient to use caused

Created on 9 May 2019  路  7Comments  路  Source: lovell/sharp

First, let's check difference of implement composition function by using sharp and jimp.
The function wil do:
1 create base transparent image with dimension

  1. receive characters and get each character's image
  2. resize character's image by provided maxHeight
  3. composite all resized image to base transparent image
  4. return composited image buffer

jimp

/*
 * @param {[]{char:string, x:number} chars - array of character and its x position
 * @param {number} charHeight - height of char on canvas
 * @param {number} maxWidth - max width of canvas, default to `charHeight * chars.length`
 * @param {string} effect='keli' text effect to use
 * @returns {buffer} rendered image buffer
 */
function renderLine(chars, charHeight, maxWidth, effect = 'keli') {
    const charsInfo = chars.map(({ char, x }) => ({
        effect: charsMap[effect][char],
        x: x
    }));

    return new Promise((resolve, reject) => {
        new jimp(maxWidth || charHeight * chars.length, charHeight, 0x0, function (err, baseImg) {
            if (err) {
                reject(err);
                return;
            }

            Promise.all(charsInfo.map(info => jimp.read(info.effect.oss).then(img => ({
                img,
                charInfo: info
            }))))
                .then((charImgs) => {
                    let exceedWidth = 0;
                    charImgs.forEach(({ img, charInfo }) => {
                        let extend_box = JSON.parse(charInfo.effect.extend_box);
                        let [top, bottom, left, right] = extend_box;
                        top = top < 0 ? -top : top;
                        left = left < 0 ? -left : left;
                        let extendHeight = 0;
                        if (top && bottom) {
                            extendHeight = top + bottom;
                        }
                        const ratio = charHeight / (img.bitmap.height - extendHeight);
                        let scaledImg = img.scale(ratio);
                        if (extendHeight > 0) {
                            let cropWidth = scaledImg.bitmap.width - (left + right) * ratio;
                            exceedWidth = exceedWidth + cropWidth - Math.floor(cropWidth);
                            cropWidth = Math.floor(cropWidth);
                            if (exceedWidth > 1) {
                                exceedWidth -= 1;
                                cropWidth += 1;
                            }
                            scaledImg = scaledImg.crop(
                                Math.round(left * ratio),
                                Math.round(top * ratio),
                                cropWidth,
                                charHeight,
                            );
                        }
                        baseImg.composite(scaledImg, charInfo.x, 0);
                    });
                    resolve({
                        img: baseImg,
                        chars,
                    });
                })
                .catch(reject);
        })
    });
}

AND sharp

function createTransparent(width, height) {
    return sharp({
        create: {
            width,
            height,
            channels: 4,
            background: { r: 0, g: 0, b: 0, alpha: 0 }
        }
    })
        .png()
        .toBuffer();
}

/*
 * @param {[]{char:string, x:number} chars - array of character and its x position
 * @param {number} charHeight - height of char on canvas
 * @param {number} maxWidth - max width of canvas, default to `charHeight * chars.length`
 * @param {string} effect='keli' text effect to use
 * @returns {jimp} rendered image jimp buffer
 */
function renderLine(chars, charHeight, maxWidth, effect = 'keli') {
    const charsInfo = chars.map(({ char, x }) => ({
        effect: charsMap[effect][char],
        x: x
    }));

    return createTransparent(maxWidth || charHeight * chars.length, charHeight)
        .then(baseImg => {
            let exceedWidth = 0;
            return Promise.all(
                charsInfo.map(charInfo => {
                    const img = sharp(charInfo.effect.oss);
                    let extend_box = JSON.parse(charInfo.effect.extend_box);
                    let [top, bottom, left, right] = extend_box;
                    top = top < 0 ? -top : top;
                    left = left < 0 ? -left : left;
                    let extendHeight = 0;
                    if (top && bottom) {
                        extendHeight = top + bottom;
                    }
                    return img
                        .metadata()
                        .then(metadata => {
                            const ratio = charHeight / (metadata.height - extendHeight);
                            const height = Math.round(ratio * metadata.height);
                            let scaledImg = img.resize({
                                height
                            });
                            return scaledImg
                                .png()
                                .toBuffer({ resolveWithObject: true })
                                .then(({ data, info }) => {
                                    let img = sharp(data);
                                    if (extendHeight > 0) {
                                        let cropWidth = info.width - (left + right) * ratio;
                                        exceedWidth = exceedWidth + cropWidth - Math.floor(cropWidth);
                                        cropWidth = Math.floor(cropWidth);
                                        if (exceedWidth > 1) {
                                            exceedWidth -= 1;
                                            cropWidth += 1;
                                        }
                                        img = img.extract({
                                            left: Math.round(left * ratio),
                                            top: Math.round(top * ratio),
                                            width: cropWidth,
                                            height: charHeight,
                                        });
                                    }
                                    //img.clone().toFile(`debug/${charInfo.effect.content}.png`);
                                    return img.png()
                                        .toBuffer()
                                        .then(buffer => ({
                                            input: buffer,
                                            left: charInfo.x,
                                            top: 0,
                                        }));
                                });
                        });
                })
            )
                .then(imgs => {
                    return sharp(baseImg)
                        .composite(imgs)
                        .png()
                        .toBuffer()
                        .then(buffer => ({
                            img: buffer,
                            chars,
                        }));
                });
        });
}

what's differences:

  1. I have to call metadata to get dimension info, which will lead to more code level
  2. I have to retrieve buffer before passing to composite caused composite don't receive Promise<buffer>
  3. I have to create new sharp object after toBuffer for later operation
  4. resize API should be a low level API, while provide better high level API like scale for common case use.
  5. For crop case, I am confused with resize and extract API, because I find that there are some issues sugguest use resize to do crop job with some complex(can't get it by first view) option, while extract API seems do the crop job well? And second thing is, why common crop function not being created or named(extract instead)?

I don't know if there's some reason that these API being designed this way, but it's better if can support promise as input and give some common functions that widely known.

question

All 7 comments

It's probably not helping much, but if you can, try to use async/await as it makes using a Promise api much cleaner.

Without checking if anything broke, transforming your sharp example to async/await looks somewhat like this:

async function renderLine(chars, charHeight, maxWidth, effect = "keli") {
  const charsInfo = chars.map(({ char, x }) => ({
    effect: charsMap[effect][char],
    x: x
  }));

  const baseImg = await createTransparent(
    maxWidth || charHeight * chars.length,
    charHeight
  );
  let exceedWidth = 0;

  const adjustments = charsInfo.map(async charInfo => {
    const {
      effect: { oss, extend_box },
      x: charLeft
    } = charInfo;

    let img = sharp(oss);
    let [top, bottom, left, right] = JSON.parse(extend_box);
    top = top < 0 ? -top : top;
    left = left < 0 ? -left : left;
    let extendHeight = 0;
    if (top && bottom) {
      extendHeight = top + bottom;
    }

    const metadata = await img.metadata();

    const ratio = charHeight / (metadata.height - extendHeight);
    const { data, info } = await img
      .resize({
        height: Math.round(ratio * metadata.height)
      })
      .png()
      .toBuffer({ resolveWithObject: true });

    let img = sharp(data);
    if (extendHeight > 0) {
      let cropWidth = info.width - (left + right) * ratio;
      exceedWidth = exceedWidth + cropWidth - Math.floor(cropWidth);
      cropWidth = Math.floor(cropWidth);
      if (exceedWidth > 1) {
        exceedWidth -= 1;
        cropWidth += 1;
      }
      img = img.extract({
        left: Math.round(left * ratio),
        top: Math.round(top * ratio),
        width: cropWidth,
        height: charHeight
      });
    }
    //img.clone().toFile(`debug/${charInfo.effect.content}.png`);
    return {
      input: await img.png().toBuffer(),
      left: charLeft,
      top: 0
    };
  });

  const imgs = await Promise.all(adjustments);

  return {
    chars,
    img: await sharp(baseImg)
      .composite(imgs)
      .png()
      .toBuffer()
  };
}

@makepanic yes, I had thought about using async/await to reduce level, it can be a choice, except one thing I need to do is try/catch it.

Inside the async function you can simply use try/catch. When consuming the async function in an async function, you can also use try/catch.
If you want to consume the async function in a regular (non-async) function you can use the returned Promise.catch method to handle thrown errors.

The use of createTransparent to create baseImg can probably be removed, using something more direct like:

-  return sharp(baseImg)
+  return sharp({
+    create: {
+      width: maxWidth || charHeight * chars.length,
+      height: charHeight,
+      channels: 4,
+      background: { r: 0, g: 0, b: 0, alpha: 0 }
+    }
+  })
     .composite(imgs)

The extract() operation is named to match the libvips operation. There used to be a crop() function that set an option for the resize() operation rather than crop, which caused confusion and hence was removed. Perhaps crop() should make a re-appearance as an alias for extract()?

236 partly covers a possible scale operation.

As an aside, and given I don't fully understand the original problem that these code excerpts are solving feel free to ignore this, but it might be that there is an alternative that avoids bitmap image composition altogether. Perhaps experiment with building a dynamic SVG with clipPath and transform attributes to crop and resize each letter in charMap.

what I want to do is render some text by scaling down image of each character, and composite them into a base canvas. SVG with clipPath seems not going to be an option.

So far, I encounter another worse problem, not the code style but performance. It seems that toBuffer operation will make some cost, but if I need to do some operation based on last operated image, I have to do toBuffer. Below is some part of my code:

            const reflectedImg = await createTransparent(
                metadata.width * 2,
                metadata.height * 2
            );
            let buffer = await img.toBuffer();
            img = await sharp(reflectedImg)
                .composite([{
                    input: buffer,
                    top: 2 * (metadata.height - boundingTop) - boundingHeight,
                    left: metadata.width - boundingLeft - boundingWidth,
                }]);
            buffer = await img.toBuffer(); // <--- I have to do this before extract, otherwise, extract will operate on image before composited
            img = sharp(buffer)
                .extract({
                    top: metadata.height,
                    left: 0,
                    width: metadata.width,
                    height: metadata.height,
                });

Will produce different result without toBuffer

            const reflectedImg = await createTransparent(
                metadata.width * 2,
                metadata.height * 2
            );
            let buffer = await img.toBuffer();
            img = await sharp(reflectedImg)
                .composite([{
                    input: buffer,
                    top: 2 * (metadata.height - boundingTop) - boundingHeight,
                    left: metadata.width - boundingLeft - boundingWidth,
                }])
                .extract({
                    top: metadata.height,
                    left: 0,
                    width: metadata.width,
                    height: metadata.height,
                });

What you describe here will probably be much better suited to the future possible enhancement detailed at #1580.

Closing in favour of existing issue #1580 as that should solve this problem.

Was this page helpful?
0 / 5 - 0 ratings