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/);
+ });
+ }
+});