From 9394a3eba102f1ff8224e8d866f13b42b7cbe009 Mon Sep 17 00:00:00 2001 From: Luxus & Invoco <196075120+ekcode001@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:29:52 -0400 Subject: [PATCH] Allow fractional resize positions --- docs/src/content/docs/api-resize.md | 8 +- lib/constructor.mjs | 1 + lib/index.d.ts | 5 +- lib/resize.mjs | 31 +++-- src/pipeline.cc | 15 ++- src/pipeline.h | 2 + test/types/sharp.test-d.cts | 12 ++ test/types/sharp.test-d.mts | 12 ++ test/unit/resize-position.js | 171 ++++++++++++++++++++++++++++ 9 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 test/unit/resize-position.js diff --git a/docs/src/content/docs/api-resize.md b/docs/src/content/docs/api-resize.md index f7e8b0050..732f883f5 100644 --- a/docs/src/content/docs/api-resize.md +++ b/docs/src/content/docs/api-resize.md @@ -23,8 +23,14 @@ When using a **fit** of `cover` or `contain`, the default **position** is `centr - `sharp.position`: `top`, `right top`, `right`, `right bottom`, `bottom`, `left bottom`, `left`, `left top`. - `sharp.gravity`: `north`, `northeast`, `east`, `southeast`, `south`, `southwest`, `west`, `northwest`, `center` or `centre`. - `sharp.strategy`: `cover` only, dynamically crop using either the `entropy` or `attention` strategy. +- `[x, y]`: horizontal and vertical alignment, each a number between `0` and `1` inclusive. + `[0, 0]` aligns to the top left, `[0.5, 0.5]` to the centre and `[1, 1]` to the bottom right. Some of these values are based on the [object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) CSS property. +Coordinates align the resized image within the output: for `cover`, `[0.25, 0.75]` removes +25% of the excess width from the left and 75% of the excess height from the top. +For `contain`, these fractions instead determine the padding on the left and top. +Offsets are rounded to the nearest pixel for `cover` and down for `contain`, matching named positions. The strategy-based approach initially resizes so one dimension is at its target length then repeatedly ranks edge regions, discarding the edge with the lowest score based on the selected strategy. @@ -61,7 +67,7 @@ Previous calls to `resize` in the same pipeline will be ignored. | [options.width] | number | | An alternative means of specifying `width`. If both are present this takes priority. | | [options.height] | number | | An alternative means of specifying `height`. If both are present this takes priority. | | [options.fit] | String | 'cover' | How the image should be resized/cropped to fit the target dimension(s), one of `cover`, `contain`, `fill`, `inside` or `outside`. | -| [options.position] | String | 'centre' | A position, gravity or strategy to use when `fit` is `cover` or `contain`. | +| [options.position] | String \| number \| Array.<number> | 'centre' | A position, gravity, strategy or `[x, y]` alignment to use when `fit` is `cover` or `contain`. Coordinates must each be between `0` and `1` inclusive. | | [options.background] | String \| Object | {r: 0, g: 0, b: 0, alpha: 1} | background colour when `fit` is `contain`, parsed by the [color](https://www.npmjs.org/package/color) module, defaults to black without transparency. | | [options.kernel] | String | 'lanczos3' | The kernel to use for image reduction and the inferred interpolator to use for upsampling. Use the `fastShrinkOnLoad` option to control kernel vs shrink-on-load. | | [options.withoutEnlargement] | Boolean | false | Do not scale up if the width *or* height are already less than the target dimensions, equivalent to GraphicsMagick's `>` geometry option. This may result in output dimensions smaller than the target dimensions. | diff --git a/lib/constructor.mjs b/lib/constructor.mjs index 5c386e440..e0d5f1037 100644 --- a/lib/constructor.mjs +++ b/lib/constructor.mjs @@ -244,6 +244,7 @@ const Sharp = function (input, options) { height: -1, canvas: 'crop', position: 0, + positionCoordinates: [], resizeBackground: [0, 0, 0, 255], angle: 0, rotationAngle: 0, diff --git a/lib/index.d.ts b/lib/index.d.ts index 072b48d86..041f56e8a 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -906,6 +906,7 @@ declare namespace sharp { * - sharp.position: top, right top, right, right bottom, bottom, left bottom, left, left top. * - sharp.gravity: north, northeast, east, southeast, south, southwest, west, northwest, center or centre. * - sharp.strategy: cover only, dynamically crop using either the entropy or attention strategy. Some of these values are based on the object-position CSS property. + * - [x, y]: horizontal and vertical alignment between 0 and 1 inclusive, from top left to bottom right. * * The strategy-based approach resizes so one dimension is at its target length then repeatedly ranks edge regions, * discarding the edge with the lowest score based on the selected strategy. @@ -1587,8 +1588,8 @@ declare namespace sharp { height?: number | undefined; /** How the image should be resized to fit both provided dimensions, one of cover, contain, fill, inside or outside. (optional, default 'cover') */ fit?: keyof FitEnum | undefined; - /** Position, gravity or strategy to use when fit is cover or contain. (optional, default 'centre') */ - position?: number | string | undefined; + /** Position, gravity, strategy or [x, y] alignment (each between 0 and 1) to use when fit is cover or contain. (optional, default 'centre') */ + position?: number | string | readonly [number, number] | undefined; /** Background colour when using a fit of contain, parsed by the color module, defaults to black without transparency. (optional, default {r:0,g:0,b:0,alpha:1}) */ background?: ColorLike | undefined; /** The kernel to use for image reduction. (optional, default 'lanczos3') */ diff --git a/lib/resize.mjs b/lib/resize.mjs index f063bfad4..c463860b1 100644 --- a/lib/resize.mjs +++ b/lib/resize.mjs @@ -135,8 +135,14 @@ function isResizeExpected (options) { * - `sharp.position`: `top`, `right top`, `right`, `right bottom`, `bottom`, `left bottom`, `left`, `left top`. * - `sharp.gravity`: `north`, `northeast`, `east`, `southeast`, `south`, `southwest`, `west`, `northwest`, `center` or `centre`. * - `sharp.strategy`: `cover` only, dynamically crop using either the `entropy` or `attention` strategy. + * - `[x, y]`: horizontal and vertical alignment, each a number between `0` and `1` inclusive. + * `[0, 0]` aligns to the top left, `[0.5, 0.5]` to the centre and `[1, 1]` to the bottom right. * * Some of these values are based on the [object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) CSS property. + * Coordinates align the resized image within the output: for `cover`, `[0.25, 0.75]` removes + * 25% of the excess width from the left and 75% of the excess height from the top. + * For `contain`, these fractions instead determine the padding on the left and top. + * Offsets are rounded to the nearest pixel for `cover` and down for `contain`, matching named positions. * * The strategy-based approach initially resizes so one dimension is at its target length * then repeatedly ranks edge regions, discarding the edge with the lowest score based on the selected strategy. @@ -246,7 +252,7 @@ function isResizeExpected (options) { * @param {number} [options.width] - An alternative means of specifying `width`. If both are present this takes priority. * @param {number} [options.height] - An alternative means of specifying `height`. If both are present this takes priority. * @param {String} [options.fit='cover'] - How the image should be resized/cropped to fit the target dimension(s), one of `cover`, `contain`, `fill`, `inside` or `outside`. - * @param {String} [options.position='centre'] - A position, gravity or strategy to use when `fit` is `cover` or `contain`. + * @param {String|number|Array} [options.position='centre'] - A position, gravity, strategy or `[x, y]` alignment to use when `fit` is `cover` or `contain`. Coordinates must each be between `0` and `1` inclusive. * @param {String|Object} [options.background={r: 0, g: 0, b: 0, alpha: 1}] - background colour when `fit` is `contain`, parsed by the [color](https://www.npmjs.org/package/color) module, defaults to black without transparency. * @param {String} [options.kernel='lanczos3'] - The kernel to use for image reduction and the inferred interpolator to use for upsampling. Use the `fastShrinkOnLoad` option to control kernel vs shrink-on-load. * @param {Boolean} [options.withoutEnlargement=false] - Do not scale up if the width *or* height are already less than the target dimensions, equivalent to GraphicsMagick's `>` geometry option. This may result in output dimensions smaller than the target dimensions. @@ -310,13 +316,24 @@ function resize (widthOrOptions, height, options) { } // Position if (is.defined(options.position)) { - const pos = is.integer(options.position) - ? options.position - : strategy[options.position] || position[options.position] || gravity[options.position]; - if (is.integer(pos) && (is.inRange(pos, 0, 8) || is.inRange(pos, 16, 17))) { - this.options.position = pos; + if (Array.isArray(options.position)) { + const [x, y] = options.position; + if (options.position.length !== 2 || !is.number(x) || !is.inRange(x, 0, 1) || + !is.number(y) || !is.inRange(y, 0, 1)) { + throw is.invalidParameterError('position', 'an array of two numbers between 0 and 1', options.position); + } + this.options.position = 0; + this.options.positionCoordinates = [x, y]; } else { - throw is.invalidParameterError('position', 'valid position/gravity/strategy', options.position); + const pos = is.integer(options.position) + ? options.position + : strategy[options.position] || position[options.position] || gravity[options.position]; + if (is.integer(pos) && (is.inRange(pos, 0, 8) || is.inRange(pos, 16, 17))) { + this.options.position = pos; + this.options.positionCoordinates = []; + } else { + throw is.invalidParameterError('position', 'valid position/gravity/strategy', options.position); + } } } // Background diff --git a/src/pipeline.cc b/src/pipeline.cc index 22e2ae085..ef716ac84 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -562,10 +562,14 @@ class PipelineWorker : public Napi::AsyncWorker { std::tie(image, background) = sharp::ApplyAlpha(image, baton->resizeBackground, shouldPremultiplyAlpha); // Embed - const auto& [left, top] = sharp::CalculateEmbedPosition( + auto [left, top] = sharp::CalculateEmbedPosition( inputWidth, inputHeight, baton->width, baton->height, baton->position); const int width = std::max(inputWidth, baton->width); const int height = std::max(inputHeight, baton->height); + if (baton->positionCoordinates.size() == 2) { + left = static_cast(std::floor((width - inputWidth) * baton->positionCoordinates[0])); + top = static_cast(std::floor((height - inputHeight) * baton->positionCoordinates[1])); + } image = nPages > 1 ? sharp::EmbedMultiPage(image, @@ -589,11 +593,15 @@ class PipelineWorker : public Napi::AsyncWorker { // Crop if (baton->position < 9) { - // Gravity-based crop - const auto& [left, top] = sharp::CalculateCrop( + // Gravity- or coordinate-based crop + auto [left, top] = sharp::CalculateCrop( inputWidth, inputHeight, baton->width, baton->height, baton->position); const int width = std::min(inputWidth, baton->width); const int height = std::min(inputHeight, baton->height); + if (baton->positionCoordinates.size() == 2) { + left = static_cast(std::round((inputWidth - width) * baton->positionCoordinates[0])); + top = static_cast(std::round((inputHeight - height) * baton->positionCoordinates[1])); + } image = nPages > 1 ? sharp::CropMultiPage(image, @@ -1796,6 +1804,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->withoutEnlargement = sharp::AttrAsBool(options, "withoutEnlargement"); baton->withoutReduction = sharp::AttrAsBool(options, "withoutReduction"); baton->position = sharp::AttrAsInt32(options, "position"); + baton->positionCoordinates = sharp::AttrAsVectorOfDouble(options, "positionCoordinates"); baton->resizeBackground = sharp::AttrAsVectorOfDouble(options, "resizeBackground"); baton->kernel = sharp::AttrAsEnum(options, "kernel", VIPS_TYPE_KERNEL); baton->fastShrinkOnLoad = sharp::AttrAsBool(options, "fastShrinkOnLoad"); diff --git a/src/pipeline.h b/src/pipeline.h index a7e30b82f..61264a8a0 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -66,6 +66,7 @@ struct PipelineBaton { VipsKernel kernel; sharp::Canvas canvas; int position; + std::vector positionCoordinates; std::vector resizeBackground; bool hasCropOffset; int cropOffsetLeft; @@ -257,6 +258,7 @@ struct PipelineBaton { kernel(VIPS_KERNEL_LANCZOS3), canvas(sharp::Canvas::CROP), position(0), + positionCoordinates{}, resizeBackground{ 0.0, 0.0, 0.0, 255.0 }, hasCropOffset(false), cropOffsetLeft(0), diff --git a/test/types/sharp.test-d.cts b/test/types/sharp.test-d.cts index deb85597b..0f12a4c9a 100644 --- a/test/types/sharp.test-d.cts +++ b/test/types/sharp.test-d.cts @@ -196,6 +196,18 @@ sharp(input) // of the image data in inputBuffer }); +// Coordinate-based resize position accepts mutable and readonly pairs. +sharp(input).resize(200, 300, { position: [0.25, 0.75] }); +sharp(input).resize({ width: 200, height: 300, fit: 'contain', position: [0.25, 0.75] as const }); +const resizePosition: [number, number] = [0.25, 0.75]; +sharp(input).resize(200, 300, { position: resizePosition }); +// @ts-expect-error +sharp(input).resize(200, 300, { position: [0.5] }); +// @ts-expect-error +sharp(input).resize(200, 300, { position: [0.5, 0.5, 0.5] }); +// @ts-expect-error +sharp(input).resize(200, 300, { position: ['0.5', 0.5] }); + sharp(input).resize({ kernel: 'mks2013' }); transformer = sharp() diff --git a/test/types/sharp.test-d.mts b/test/types/sharp.test-d.mts index 4fadc5fcc..0cc92ef6a 100644 --- a/test/types/sharp.test-d.mts +++ b/test/types/sharp.test-d.mts @@ -196,6 +196,18 @@ sharp(input) // of the image data in inputBuffer }); +// Coordinate-based resize position accepts mutable and readonly pairs. +sharp(input).resize(200, 300, { position: [0.25, 0.75] }); +sharp(input).resize({ width: 200, height: 300, fit: 'contain', position: [0.25, 0.75] as const }); +const resizePosition: [number, number] = [0.25, 0.75]; +sharp(input).resize(200, 300, { position: resizePosition }); +// @ts-expect-error +sharp(input).resize(200, 300, { position: [0.5] }); +// @ts-expect-error +sharp(input).resize(200, 300, { position: [0.5, 0.5, 0.5] }); +// @ts-expect-error +sharp(input).resize(200, 300, { position: ['0.5', 0.5] }); + sharp(input).resize({ kernel: 'mks2013' }); transformer = sharp() diff --git a/test/unit/resize-position.js b/test/unit/resize-position.js new file mode 100644 index 000000000..c7e20efc1 --- /dev/null +++ b/test/unit/resize-position.js @@ -0,0 +1,171 @@ +/*! + SPDX-FileCopyrightText: 2013 Lovell Fuller and others + SPDX-License-Identifier: Apache-2.0 +*/ + +const { suite, test } = require('node:test'); + +const sharp = require('../../'); + +// Synthetic pixels vary on both axes so a misplaced crop cannot pass unnoticed. +function input (width, height, pageHeight) { + const data = Buffer.alloc(width * height * 3); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const offset = (y * width + x) * 3; + data[offset] = x * 7 % 256; + data[offset + 1] = y * 11 % 256; + data[offset + 2] = (x + y) * 13 % 256; + } + } + return sharp(data, { raw: { width, height, channels: 3, pageHeight }, animated: Boolean(pageHeight) }); +} + +suite('Resize coordinate position', () => { + const positions = [ + ['northwest', [0, 0]], ['north', [0.5, 0]], ['northeast', [1, 0]], + ['west', [0, 0.5]], ['centre', [0.5, 0.5]], ['east', [1, 0.5]], + ['southwest', [0, 1]], ['south', [0.5, 1]], ['southeast', [1, 1]] + ]; + + for (const fit of ['cover', 'contain']) { + for (const [width, height] of [[20, 12], [21, 12], [12, 20], [12, 21]]) { + for (const [name, position] of positions) { + test(`${fit} ${width}x${height} matches ${name}`, async (t) => { + const size = fit === 'cover' ? 12 : Math.max(width, height); + const actual = await input(width, height).resize(size, size, { fit, position }).raw().toBuffer(); + const expected = await input(width, height).resize(size, size, { fit, position: name }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + } + } + } + + for (const [width, height, left, top] of [[21, 12, 2, 0], [12, 21, 0, 7]]) { + test(`cover ${width}x${height} at [0.25, 0.75]`, async (t) => { + const actual = await input(width, height).resize(12, 12, { position: [0.25, 0.75] }).raw().toBuffer(); + const expected = await input(width, height).extract({ left, top, width: 12, height: 12 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + } + + for (const [width, height, left, top, right, bottom] of [[21, 12, 0, 6, 0, 3], [12, 21, 2, 0, 7, 0]]) { + test(`contain ${width}x${height} at [0.25, 0.75]`, async (t) => { + const actual = await input(width, height) + .resize(21, 21, { fit: 'contain', position: [0.25, 0.75], background: 'white' }).raw().toBuffer(); + const expected = await input(width, height) + .extend({ left, top, right, bottom, background: 'white' }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + } + + for (const [width, height] of [[42, 24], [21, 12]]) { + test(`position applies after resizing ${width}x${height}`, async (t) => { + const actual = await input(width, height) + .resize(24, 24, { position: [0.25, 0.75], kernel: 'nearest' }).raw().toBuffer(); + const expected = await input(width, height) + .resize(42, 24, { fit: 'fill', kernel: 'nearest' }) + .extract({ left: 5, top: 0, width: 24, height: 24 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + } + + test('position applies after downscaling', async (t) => { + const actual = await input(42, 24) + .resize(12, 12, { position: [0.25, 0.75], kernel: 'nearest' }).raw().toBuffer(); + const expected = await input(42, 24) + .resize(21, 12, { fit: 'fill', kernel: 'nearest' }) + .extract({ left: 2, top: 0, width: 12, height: 12 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + + test('without enlargement clamps the crop to the resized image', async (t) => { + const actual = await input(21, 12) + .resize(16, 16, { position: [0.25, 0.75], withoutEnlargement: true }).raw().toBuffer(); + const expected = await input(21, 12).extract({ left: 1, top: 0, width: 16, height: 12 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + + test('without reduction positions both axes', async (t) => { + const actual = await input(21, 17) + .resize(12, 12, { position: [0.25, 0.75], withoutReduction: true }).raw().toBuffer(); + const expected = await input(21, 17).extract({ left: 2, top: 4, width: 12, height: 12 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + + test('auto-orientation happens before positioning', async (t) => { + const source = await input(21, 12).withMetadata({ orientation: 6 }).png().toBuffer(); + const actual = await sharp(source).autoOrient() + .resize(12, 12, { position: [0.25, 0.75] }).raw().toBuffer(); + const expected = await input(21, 12).rotate(90) + .extract({ left: 0, top: 7, width: 12, height: 12 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + + for (const fit of ['cover', 'contain']) { + test(`${fit} positions each frame separately`, async (t) => { + const size = fit === 'cover' ? 12 : 21; + const actual = await input(12, 42, 21) + .resize(size, size, { fit, position: [0.25, 0.75] }).raw().toBuffer({ resolveWithObject: true }); + const source = await input(12, 42).raw().toBuffer(); + const frames = []; + for (let page = 0; page < 2; page++) { + const frame = sharp(source.subarray(page * 12 * 21 * 3, (page + 1) * 12 * 21 * 3), { + raw: { width: 12, height: 21, channels: 3 } + }); + frames.push(await (fit === 'cover' + ? frame.extract({ left: 0, top: 7, width: 12, height: 12 }) + : frame.extend({ left: 2, right: 7, top: 0, bottom: 0 })).raw().toBuffer()); + } + t.assert.deepStrictEqual(actual.data, Buffer.concat(frames)); + t.assert.strictEqual(actual.info.pageHeight, size); + t.assert.strictEqual(actual.info.pages, 2); + }); + } + + for (const position of ['east', sharp.strategy.entropy, sharp.strategy.attention]) { + test(`a later position ${position} replaces coordinates`, async (t) => { + const actual = await input(21, 12).resize(12, 12, { position: [0.25, 0.75] }) + .resize(12, 12, { position }).raw().toBuffer(); + const expected = await input(21, 12).resize(12, 12, { position }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + } + + test('coordinates replace a previous strategy', async (t) => { + const actual = await input(21, 12).resize(12, 12, { position: sharp.strategy.entropy }) + .resize(12, 12, { position: [0.25, 0.75] }).raw().toBuffer(); + const expected = await input(21, 12).extract({ left: 2, top: 0, width: 12, height: 12 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + + test('coordinates are copied when supplied', async (t) => { + const position = [0.25, 0.75]; + const pipeline = input(21, 12).resize(12, 12, { position }); + position[0] = 1; + const actual = await pipeline.raw().toBuffer(); + const expected = await input(21, 12).extract({ left: 2, top: 0, width: 12, height: 12 }).raw().toBuffer(); + t.assert.deepStrictEqual(actual, expected); + }); + + test('clone position does not change the original pipeline', async (t) => { + const pipeline = input(21, 12).resize(12, 12, { position: [0.25, 0.75] }); + const clone = pipeline.clone().resize(12, 12, { position: [1, 0.5] }); + t.assert.deepStrictEqual(await pipeline.raw().toBuffer(), + await input(21, 12).extract({ left: 2, top: 0, width: 12, height: 12 }).raw().toBuffer()); + t.assert.deepStrictEqual(await clone.raw().toBuffer(), + await input(21, 12).extract({ left: 9, top: 0, width: 12, height: 12 }).raw().toBuffer()); + }); + + for (const position of [ + [], [0.5], [0.5, 0.5, 0.5], ['0.5', 0.5], [0.5, '0.5'], + [NaN, 0.5], [0.5, NaN], [Infinity, 0.5], [0.5, -Infinity], + [-0.1, 0.5], [1.1, 0.5], [0.5, -0.1], [0.5, 1.1], + [null, 0.5], [0.5, undefined], Array(2) + ]) { + test(`rejects invalid coordinates ${String(position)}`, (t) => { + t.assert.throws(() => sharp().resize(12, 12, { position }), + /Expected an array of two numbers between 0 and 1 for position/); + }); + } +});