question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Calculating "fit over" dimensions (fit on either axis)

See original GitHub issue

Feature request

What are you trying to achieve?

Calculate “fit over” dimensions.

This is the term that I recall some image programs using in the past - I can’t really explain why it’s named that way, but basically this means:

Constrain the width and height to a given “size” on either axis.

So, if the image is wide, constrain the width - if it’s tall, constrain the height.

This is useful when you want to constrain the overall number of pixels - for example, assuming you have mixed content with both portrait and landscape images, let’s say you want to produce full-screen images to use on phones and tablets, where the display can be rotated.

When you searched for similar feature requests, what did you find that might be related?

Nuthin’.

What would you expect the API to look like?

I don’t know if this is a good fit for the existing resize options API - having another option could get confusing, as this likely won’t “play nice” or make sense in combination with some of the other existing options.

What alternatives have you considered?

The better solution might be another documentation entry.

Here’s what I came up with:

function fitTo(size, width, height, { withoutEnlargement, withoutReduction } = {}) {
  let ratio = width > height
    ? size / width
    : size / height;

  if (withoutEnlargement) {
    ratio = Math.min(ratio, 1);
  }

  if (withoutReduction) {
    ratio = Math.max(ratio, 1);
  }
  
  return {
    width: Math.round(width * ratio),
    height: Math.round(height * ratio),
    ratio
  }
}

This will constrain the given width or height to a given size, while preserving proportions.

I added options to constrain the resulting width and height to ratios either withoutEnlargement or withoutReduction - these are identical to how the resize options work.

I’m not certain if these options are necessary - I mean, you could just pass the unconstrained dimensions and the same options to resize after, so maybe this is enough:

function fitTo(size, width, height) {
  let ratio = width > height
    ? size / width
    : size / height;
  
  return {
    width: Math.round(width * ratio),
    height: Math.round(height * ratio),
    ratio
  }
}

Alternatively, maybe we could add a size option to resize, although as said, this might get confusing, since it would have to ignore width and height if size is specified.

This function requires you first obtain the width and height from metadata, which could be an argument for actually including this feature in the API somehow - if we just add an example to the documentation, it’s hard to say if it belongs in documentation for resize or metadata. (If you’re trying to resize an image to fit, you’re most likely looking at the documentation for resize - but the function itself requires information from metadata, so which does it relate more to?)

Issue Analytics

  • State:open
  • Created 2 years ago
  • Comments:5 (2 by maintainers)

github_iconTop GitHub Comments

1reaction
ssendevcommented, Nov 20, 2022
Ok this was definitely more involved than I anticipated but here it is:
function resizeSize(
  width: number,
  height: number,
  targetWidth: number | undefined = 0,
  targetHeight: number | undefined = 0,
  fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside',
  withoutEnlargement: boolean,
  withoutReduction: boolean,
  format: 'jpg' | 'svg' | 'pdf' | 'webp' | string,
  fastShrinkOnLoad: boolean,
  noShrinkOnLoad?: boolean
): [number, number] {
  let [hshrink, vshrink] = resolveShrink(
    width,
    height,
    targetWidth,
    targetHeight,
    fit,
    withoutEnlargement,
    withoutReduction
  )

  // The jpeg preload shrink.
  let jpegShrinkOnLoad = 1

  // WebP, PDF, SVG scale
  let scale = 1.0

  // Try to reload input using shrink-on-load for JPEG, WebP, SVG and PDF, when:
  //  - the width or height parameters are specified;
  //  - gamma correction doesn't need to be applied;
  //  - trimming or pre-resize extract isn't required;
  //  - input colourspace is not specified;
  const shouldPreShrink =
    (targetWidth > 0 || targetHeight > 0) && !noShrinkOnLoad
  // && baton->gamma == 0 && baton->topOffsetPre == -1 && baton->trimThreshold == 0.0 &&
  //  baton->colourspaceInput == VIPS_INTERPRETATION_LAST && !shouldRotateBefore;

  if (shouldPreShrink) {
    // The common part of the shrink: the bit by which both axes must be shrunk
    const shrink = Math.min(hshrink, vshrink)

    if (format === 'jpg') {
      // Leave at least a factor of two for the final resize step, when fastShrinkOnLoad: false
      // for more consistent results and to avoid extra sharpness to the image
      const factor = fastShrinkOnLoad ? 1 : 2
      if (shrink >= 8 * factor) {
        jpegShrinkOnLoad = 8
      } else if (shrink >= 4 * factor) {
        jpegShrinkOnLoad = 4
      } else if (shrink >= 2 * factor) {
        jpegShrinkOnLoad = 2
      }
      // Lower shrink-on-load for known libjpeg rounding errors
      if (jpegShrinkOnLoad > 1 && Math.round(shrink) == jpegShrinkOnLoad) {
        jpegShrinkOnLoad /= 2
      }
    } else if (format === 'webp' && shrink > 1.0) {
      // Avoid upscaling via webp
      scale = 1.0 / shrink
    } else if (format === 'svg' || format === 'pdf') {
      scale = 1.0 / shrink
    }
  }

  let inputWidth
  let inputHeight

  if (scale !== 1.0 || jpegShrinkOnLoad > 1) {
    // Size after pre shrinking
    if (jpegShrinkOnLoad > 1) {
      inputWidth = Math.floor(width / jpegShrinkOnLoad)
      inputHeight = Math.floor(height / jpegShrinkOnLoad)
    } else {
      inputWidth = Math.round(width * scale)
      inputHeight = Math.round(height * scale)
    }

    const shrunk = resolveShrink(
      inputWidth,
      inputHeight,
      targetWidth,
      targetHeight,
      fit,
      withoutEnlargement,
      withoutReduction
    )
    hshrink = shrunk[0]
    vshrink = shrunk[1]

    // Size after shrinking
    inputWidth = Math.round(inputWidth / hshrink)
    inputHeight = Math.round(inputHeight / vshrink)
  } else {
    // Size after shrinking
    inputWidth = Math.round(width / hshrink)
    inputHeight = Math.round(height / vshrink)
  }

  // Resolve dimensions
  if (!targetWidth) {
    targetWidth = inputWidth
  }
  if (!targetHeight) {
    targetHeight = inputHeight
  }

  // Crop/embed
  if (inputWidth != targetWidth || inputHeight != targetHeight) {
    if (fit === 'contain') {
      inputWidth = Math.max(inputWidth, targetWidth)
      inputHeight = Math.max(inputHeight, targetHeight)
    } else if (fit === 'cover') {
      if (targetWidth > inputWidth) {
        targetWidth = inputWidth
      }
      if (targetHeight > inputHeight) {
        targetHeight = inputHeight
      }
      inputWidth = Math.min(inputWidth, targetWidth)
      inputHeight = Math.min(inputHeight, targetHeight)
    }
  }

  return [inputWidth, inputHeight]
}

function resolveShrink(
  width: number,
  height: number,
  targetWidth: number | undefined = 0,
  targetHeight: number | undefined = 0,
  fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside',
  withoutEnlargement: boolean,
  withoutReduction: boolean
) {
  // if (swap && fit !== 'fill') {
  //   // Swap input width and height when requested.
  //   std::swap(width, height);
  // }

  let hshrink = 1.0
  let vshrink = 1.0

  if (targetWidth > 0 && targetHeight > 0) {
    // Fixed width and height
    hshrink = width / targetWidth
    vshrink = height / targetHeight

    switch (fit) {
      case 'cover':
      case 'outside':
        if (hshrink < vshrink) {
          vshrink = hshrink
        } else {
          hshrink = vshrink
        }
        break
      case 'contain':
      case 'inside':
        if (hshrink > vshrink) {
          vshrink = hshrink
        } else {
          hshrink = vshrink
        }
        break
      case 'fill':
        break
    }
  } else if (targetWidth > 0) {
    // Fixed width
    hshrink = width / targetWidth

    if (fit !== 'fill') {
      // Auto height
      vshrink = hshrink
    }
  } else if (targetHeight > 0) {
    // Fixed height
    vshrink = height / targetHeight

    if (fit !== 'fill') {
      // Auto width
      hshrink = vshrink
    }
  }

  // We should not reduce or enlarge the output image, if
  // withoutReduction or withoutEnlargement is specified.
  if (withoutReduction) {
    // Equivalent of VIPS_SIZE_UP
    hshrink = Math.min(1.0, hshrink)
    vshrink = Math.min(1.0, vshrink)
  } else if (withoutEnlargement) {
    // Equivalent of VIPS_SIZE_DOWN
    hshrink = Math.max(1.0, hshrink)
    vshrink = Math.max(1.0, vshrink)
  }

  // We don't want to shrink so much that we send an axis to 0
  hshrink = Math.min(hshrink, width)
  vshrink = Math.min(vshrink, height)

  if (fit === 'fill') {
    return [hshrink, vshrink]
  }
  return [vshrink, hshrink]
}


async function test() {
  sharp.concurrency(0)
  const start = 1
  const n = start + 100
  const width = 1000
  const height = 999
  const noPreshrink = false
  const xy = 50

  let ok = 0
  let failed = 0
  let err = 0
  for (const format of ['jpg', 'png', 'webp'] as const) {
    process.stdout.write(format + ' ')
    const img = sharp(
      await sharp({
        create: { width, height, background: 'white', channels: 3 },
      })
        .toFormat(format)
        .toBuffer()
    )
    for (const side of ['x', 'y', 'xy']) {
      process.stdout.write(side + ' ')
      for (const fit of [
        'cover',
        'contain',
        'fill',
        'inside',
        'outside',
      ] as const) {
        process.stdout.write(fit + ' ')

        const modes = ['on', 'noEnl', 'noRed']
        for (const mode of modes) {
          process.stdout.write(mode + ' ')

          const withoutEnlargement = mode === 'noEnl'
          const withoutReduction = mode === 'noRed'

          for (let i = start; i <= n; i++) {
            const targetWidth = side == 'x' ? i : side == 'xy' ? i : undefined
            const targetHeight = side == 'y' ? i : side == 'xy' ? xy : undefined

            try {
              let img2 = img.clone()
              if (noPreshrink) {
                img2 = img2.extract({ top: 0, left: 0, height, width })
              }
              const resized = await img2
                .resize({
                  width: targetWidth,
                  height: targetHeight,
                  withoutEnlargement,
                  withoutReduction,
                  fit,
                })
                .toFormat('jpg')
                .toBuffer()
              const meta = await sharp(resized).metadata()

              const [isW, isH] = resizeSize(
                width,
                height,
                targetWidth,
                targetHeight,
                fit,
                withoutEnlargement,
                withoutReduction,
                format,
                true,
                noPreshrink
              )

              if (meta.width === isW && meta.height === isH) {
                ok++
                process.stdout.write('.')
              } else {
                failed++
                process.stdout.write('\n')
                console.log({
                  format,
                  fit,
                  noEnl: withoutEnlargement,
                  noRed: withoutReduction,
                  file: { w: width, h: height },
                  trgt: { w: targetWidth, h: targetHeight },
                  real: { w: meta.width, h: meta.height },
                  calc: { w: isW, h: isH },
                })
              }
            } catch (e) {
              err++
              process.stdout.write('\n')
              console.log(e, {
                format,
                fit,
                noEnl: withoutEnlargement,
                noRed: withoutReduction,
                file: { w: width, h: height },
                trgt: { w: targetWidth, h: targetHeight },
              })
            }
          }
        }
      }
    }
  }
  console.log(`\ntests ok: ${ok}, failed: ${failed}, error: ${err}`)
}

This is a port from pipeline.cc only noPreshrink must be manually specified which can be determined as follows

// Try to reload input using shrink-on-load for JPEG, WebP, SVG and PDF, when:
//  - the width or height parameters are specified;
//  - gamma correction doesn't need to be applied;
//  - trimming or pre-resize extract isn't required;
//  - input colourspace is not specified;

Although I’m not sure if this can be known from the output of .metadata() maybe space: 'srgb' and hasProfile: false is enough? if not it would be good to add missing stuff to metadata output.

One thing which didn’t work was fit: 'fill' there I had to swap the axes at the end of resolveShrink.

0reactions
pr0n1x2commented, Nov 27, 2022

@ssendev Great job, thanks a lot. I have been looking for a solution for several days and already thought of digging into the source codes of C. But you did it before me and saved a lot of time. Thanks again.

I see so many requests from people to make this functionality part of the library, I don’t understand why it’s being ignored.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Line of Best Fit (Least Square Method)
A line of best fit can be roughly determined using an eyeball method by drawing a straight line on a scatter plot so...
Read more >
True Position – Position Tolerance | GD&T Basics
The Position tolerance is the GD&T symbol and tolerance of location. The True Position is the exact coordinate, or location defined by basic...
Read more >
Dimensioning and Tolerancing
The exact shape of an object is communicated through orthographic drawings, which are developed following standard drawing practices. The process of adding size....
Read more >
Evaluate a Curve Fit - MATLAB & Simulink
Specify a coefficient by name. ... Get all the coefficient names. Look at the fit equation (for example, f(x) = p1*x^3+... ) to...
Read more >
How to Determine Bearing Shaft and Housing Fit
Correcting the fit can also be very difficult, depending on the application. Typically this will require an entire teardown to access both the...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found