From e26d4e9d5b9207c9db575b6d1f73d40c1e892538 Mon Sep 17 00:00:00 2001 From: Lovell Fuller Date: Sat, 21 Jun 2025 11:33:52 +0100 Subject: [PATCH] Add pageHeight param to create/new for animated input #3236 --- docs/src/content/docs/api-constructor.md | 6 ++- docs/src/content/docs/changelog.md | 3 ++ lib/constructor.js | 6 ++- lib/index.d.ts | 7 ++- lib/input.js | 58 +++++++++++++++++++----- package.json | 6 +-- src/common.cc | 10 ++++ src/common.h | 4 ++ test/types/sharp.test-d.ts | 8 ++++ test/unit/noise.js | 45 ++++++++++++++++++ test/unit/raw.js | 46 +++++++++++++++++++ 11 files changed, 179 insertions(+), 20 deletions(-) diff --git a/docs/src/content/docs/api-constructor.md b/docs/src/content/docs/api-constructor.md index 139e08f4..3504239d 100644 --- a/docs/src/content/docs/api-constructor.md +++ b/docs/src/content/docs/api-constructor.md @@ -50,15 +50,17 @@ where the overall height is the `pageHeight` multiplied by the number of `pages` | [options.raw.height] | number | | integral number of pixels high. | | [options.raw.channels] | number | | integral number of channels, between 1 and 4. | | [options.raw.premultiplied] | boolean | | specifies that the raw input has already been premultiplied, set to `true` to avoid sharp premultiplying the image. (optional, default `false`) | +| [options.raw.pageHeight] | number | | The pixel height of each page/frame for animated images, must be an integral factor of `raw.height`. | | [options.create] | Object | | describes a new image to be created. | | [options.create.width] | number | | integral number of pixels wide. | | [options.create.height] | number | | integral number of pixels high. | | [options.create.channels] | number | | integral number of channels, either 3 (RGB) or 4 (RGBA). | | [options.create.background] | string \| Object | | parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. | +| [options.create.pageHeight] | number | | The pixel height of each page/frame for animated images, must be an integral factor of `create.height`. | | [options.create.noise] | Object | | describes a noise to be created. | | [options.create.noise.type] | string | | type of generated noise, currently only `gaussian` is supported. | -| [options.create.noise.mean] | number | | mean of pixels in generated noise. | -| [options.create.noise.sigma] | number | | standard deviation of pixels in generated noise. | +| [options.create.noise.mean] | number | 128 | Mean value of pixels in the generated noise. | +| [options.create.noise.sigma] | number | 30 | Standard deviation of pixel values in the generated noise. | | [options.text] | Object | | describes a new text image to be created. | | [options.text.text] | string | | text to render as a UTF-8 string. It can contain Pango markup, for example `LeMonde`. | | [options.text.font] | string | | font name to render with. | diff --git a/docs/src/content/docs/changelog.md b/docs/src/content/docs/changelog.md index d39669d7..0a248750 100644 --- a/docs/src/content/docs/changelog.md +++ b/docs/src/content/docs/changelog.md @@ -18,6 +18,9 @@ Requires libvips v8.17.0 * Expose `keepDuplicateFrames` GIF output parameter. +* Add `pageHeight` option to `create` and `raw` input for animated images. + [#3236](https://github.com/lovell/sharp/issues/3236) + * Expose JPEG 2000 `oneshot` decoder option. [#4262](https://github.com/lovell/sharp/pull/4262) [@mbklein](https://github.com/mbklein) diff --git a/lib/constructor.js b/lib/constructor.js index daac3b5f..e5d2a0c5 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -160,15 +160,17 @@ const debuglog = util.debuglog('sharp'); * @param {number} [options.raw.channels] - integral number of channels, between 1 and 4. * @param {boolean} [options.raw.premultiplied] - specifies that the raw input has already been premultiplied, set to `true` * to avoid sharp premultiplying the image. (optional, default `false`) + * @param {number} [options.raw.pageHeight] - The pixel height of each page/frame for animated images, must be an integral factor of `raw.height`. * @param {Object} [options.create] - describes a new image to be created. * @param {number} [options.create.width] - integral number of pixels wide. * @param {number} [options.create.height] - integral number of pixels high. * @param {number} [options.create.channels] - integral number of channels, either 3 (RGB) or 4 (RGBA). * @param {string|Object} [options.create.background] - parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. + * @param {number} [options.create.pageHeight] - The pixel height of each page/frame for animated images, must be an integral factor of `create.height`. * @param {Object} [options.create.noise] - describes a noise to be created. * @param {string} [options.create.noise.type] - type of generated noise, currently only `gaussian` is supported. - * @param {number} [options.create.noise.mean] - mean of pixels in generated noise. - * @param {number} [options.create.noise.sigma] - standard deviation of pixels in generated noise. + * @param {number} [options.create.noise.mean=128] - Mean value of pixels in the generated noise. + * @param {number} [options.create.noise.sigma=30] - Standard deviation of pixel values in the generated noise. * @param {Object} [options.text] - describes a new text image to be created. * @param {string} [options.text.text] - text to render as a UTF-8 string. It can contain Pango markup, for example `LeMonde`. * @param {string} [options.text.font] - font name to render with. diff --git a/lib/index.d.ts b/lib/index.d.ts index 4b39148e..395ec977 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1061,6 +1061,8 @@ declare namespace sharp { interface CreateRaw extends Raw { /** Specifies that the raw input has already been premultiplied, set to true to avoid sharp premultiplying the image. (optional, default false) */ premultiplied?: boolean | undefined; + /** The height of each page/frame for animated images, must be an integral factor of the overall image height. */ + pageHeight?: number | undefined; } type CreateChannels = 3 | 4; @@ -1076,6 +1078,9 @@ declare namespace sharp { background: Colour | Color; /** Describes a noise to be created. */ noise?: Noise | undefined; + /** The height of each page/frame for animated images, must be an integral factor of the overall image height. */ + pageHeight?: number | undefined; + } interface CreateText { @@ -1549,7 +1554,7 @@ declare namespace sharp { interface Noise { /** type of generated noise, currently only gaussian is supported. */ - type?: 'gaussian' | undefined; + type: 'gaussian'; /** mean of pixels in generated noise. */ mean?: number | undefined; /** standard deviation of pixels in generated noise. */ diff --git a/lib/input.js b/lib/input.js index 436bfb75..ceb6d854 100644 --- a/lib/input.js +++ b/lib/input.js @@ -185,8 +185,6 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { inputDescriptor.rawWidth = inputOptions.raw.width; inputDescriptor.rawHeight = inputOptions.raw.height; inputDescriptor.rawChannels = inputOptions.raw.channels; - inputDescriptor.rawPremultiplied = !!inputOptions.raw.premultiplied; - switch (input.constructor) { case Uint8Array: case Uint8ClampedArray: @@ -220,6 +218,25 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { } else { throw new Error('Expected width, height and channels for raw pixel input'); } + inputDescriptor.rawPremultiplied = false; + if (is.defined(inputOptions.raw.premultiplied)) { + if (is.bool(inputOptions.raw.premultiplied)) { + inputDescriptor.rawPremultiplied = inputOptions.raw.premultiplied; + } else { + throw is.invalidParameterError('raw.premultiplied', 'boolean', inputOptions.raw.premultiplied); + } + } + inputDescriptor.rawPageHeight = 0; + if (is.defined(inputOptions.raw.pageHeight)) { + if (is.integer(inputOptions.raw.pageHeight) && inputOptions.raw.pageHeight > 0 && inputOptions.raw.pageHeight <= inputOptions.raw.height) { + if (inputOptions.raw.height % inputOptions.raw.pageHeight !== 0) { + throw new Error(`Expected raw.height ${inputOptions.raw.height} to be a multiple of raw.pageHeight ${inputOptions.raw.pageHeight}`); + } + inputDescriptor.rawPageHeight = inputOptions.raw.pageHeight; + } else { + throw is.invalidParameterError('raw.pageHeight', 'positive integer', inputOptions.raw.pageHeight); + } + } } // Multi-page input (GIF, TIFF, PDF) if (is.defined(inputOptions.animated)) { @@ -316,27 +333,44 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { inputDescriptor.createWidth = inputOptions.create.width; inputDescriptor.createHeight = inputOptions.create.height; inputDescriptor.createChannels = inputOptions.create.channels; + inputDescriptor.createPageHeight = 0; + if (is.defined(inputOptions.create.pageHeight)) { + if (is.integer(inputOptions.create.pageHeight) && inputOptions.create.pageHeight > 0 && inputOptions.create.pageHeight <= inputOptions.create.height) { + if (inputOptions.create.height % inputOptions.create.pageHeight !== 0) { + throw new Error(`Expected create.height ${inputOptions.create.height} to be a multiple of create.pageHeight ${inputOptions.create.pageHeight}`); + } + inputDescriptor.createPageHeight = inputOptions.create.pageHeight; + } else { + throw is.invalidParameterError('create.pageHeight', 'positive integer', inputOptions.create.pageHeight); + } + } // Noise if (is.defined(inputOptions.create.noise)) { if (!is.object(inputOptions.create.noise)) { throw new Error('Expected noise to be an object'); } - if (!is.inArray(inputOptions.create.noise.type, ['gaussian'])) { + if (inputOptions.create.noise.type !== 'gaussian') { throw new Error('Only gaussian noise is supported at the moment'); } + inputDescriptor.createNoiseType = inputOptions.create.noise.type; if (!is.inRange(inputOptions.create.channels, 1, 4)) { throw is.invalidParameterError('create.channels', 'number between 1 and 4', inputOptions.create.channels); } - inputDescriptor.createNoiseType = inputOptions.create.noise.type; - if (is.number(inputOptions.create.noise.mean) && is.inRange(inputOptions.create.noise.mean, 0, 10000)) { - inputDescriptor.createNoiseMean = inputOptions.create.noise.mean; - } else { - throw is.invalidParameterError('create.noise.mean', 'number between 0 and 10000', inputOptions.create.noise.mean); + inputDescriptor.createNoiseMean = 128; + if (is.defined(inputOptions.create.noise.mean)) { + if (is.number(inputOptions.create.noise.mean) && is.inRange(inputOptions.create.noise.mean, 0, 10000)) { + inputDescriptor.createNoiseMean = inputOptions.create.noise.mean; + } else { + throw is.invalidParameterError('create.noise.mean', 'number between 0 and 10000', inputOptions.create.noise.mean); + } } - if (is.number(inputOptions.create.noise.sigma) && is.inRange(inputOptions.create.noise.sigma, 0, 10000)) { - inputDescriptor.createNoiseSigma = inputOptions.create.noise.sigma; - } else { - throw is.invalidParameterError('create.noise.sigma', 'number between 0 and 10000', inputOptions.create.noise.sigma); + inputDescriptor.createNoiseSigma = 30; + if (is.defined(inputOptions.create.noise.sigma)) { + if (is.number(inputOptions.create.noise.sigma) && is.inRange(inputOptions.create.noise.sigma, 0, 10000)) { + inputDescriptor.createNoiseSigma = inputOptions.create.noise.sigma; + } else { + throw is.invalidParameterError('create.noise.sigma', 'number between 0 and 10000', inputOptions.create.noise.sigma); + } } } else if (is.defined(inputOptions.create.background)) { if (!is.inRange(inputOptions.create.channels, 3, 4)) { diff --git a/package.json b/package.json index 13816bce..a3d2a9ed 100644 --- a/package.json +++ b/package.json @@ -179,12 +179,12 @@ "icc": "^3.0.0", "jsdoc-to-markdown": "^9.1.1", "license-checker": "^25.0.1", - "mocha": "^11.6.0", - "node-addon-api": "^8.3.1", + "mocha": "^11.7.0", + "node-addon-api": "^8.4.0", "node-gyp": "^11.2.0", "nyc": "^17.1.0", "semistandard": "^17.0.0", - "tar-fs": "^3.0.9", + "tar-fs": "^3.0.10", "tsd": "^0.32.0" }, "license": "Apache-2.0", diff --git a/src/common.cc b/src/common.cc index e465dc2f..df779e8b 100644 --- a/src/common.cc +++ b/src/common.cc @@ -93,6 +93,7 @@ namespace sharp { descriptor->rawWidth = AttrAsUint32(input, "rawWidth"); descriptor->rawHeight = AttrAsUint32(input, "rawHeight"); descriptor->rawPremultiplied = AttrAsBool(input, "rawPremultiplied"); + descriptor->rawPageHeight = AttrAsUint32(input, "rawPageHeight"); } // Multi-page input (GIF, TIFF, PDF) if (HasAttr(input, "pages")) { @@ -129,6 +130,7 @@ namespace sharp { descriptor->createChannels = AttrAsUint32(input, "createChannels"); descriptor->createWidth = AttrAsUint32(input, "createWidth"); descriptor->createHeight = AttrAsUint32(input, "createHeight"); + descriptor->createPageHeight = AttrAsUint32(input, "createPageHeight"); if (HasAttr(input, "createNoiseType")) { descriptor->createNoiseType = AttrAsStr(input, "createNoiseType"); descriptor->createNoiseMean = AttrAsDouble(input, "createNoiseMean"); @@ -453,6 +455,10 @@ namespace sharp { } else { image.get_image()->Type = is8bit ? VIPS_INTERPRETATION_sRGB : VIPS_INTERPRETATION_RGB16; } + if (descriptor->rawPageHeight > 0) { + image.set(VIPS_META_PAGE_HEIGHT, descriptor->rawPageHeight); + image.set(VIPS_META_N_PAGES, static_cast(descriptor->rawHeight / descriptor->rawPageHeight)); + } if (descriptor->rawPremultiplied) { image = image.unpremultiply(); } @@ -502,6 +508,10 @@ namespace sharp { channels < 3 ? VIPS_INTERPRETATION_B_W : VIPS_INTERPRETATION_sRGB)) .new_from_image(background); } + if (descriptor->createPageHeight > 0) { + image.set(VIPS_META_PAGE_HEIGHT, descriptor->createPageHeight); + image.set(VIPS_META_N_PAGES, static_cast(descriptor->createHeight / descriptor->createPageHeight)); + } image = image.cast(VIPS_FORMAT_UCHAR); imageType = ImageType::RAW; } else if (descriptor->textValue.length() > 0) { diff --git a/src/common.h b/src/common.h index e199942d..40048aa2 100644 --- a/src/common.h +++ b/src/common.h @@ -48,11 +48,13 @@ namespace sharp { int rawWidth; int rawHeight; bool rawPremultiplied; + int rawPageHeight; int pages; int page; int createChannels; int createWidth; int createHeight; + int createPageHeight; std::vector createBackground; std::string createNoiseType; double createNoiseMean; @@ -98,11 +100,13 @@ namespace sharp { rawWidth(0), rawHeight(0), rawPremultiplied(false), + rawPageHeight(0), pages(1), page(0), createChannels(0), createWidth(0), createHeight(0), + createPageHeight(0), createBackground{ 0.0, 0.0, 0.0, 255.0 }, createNoiseMean(0.0), createNoiseSigma(0.0), diff --git a/test/types/sharp.test-d.ts b/test/types/sharp.test-d.ts index d00cbd29..2d65eb49 100644 --- a/test/types/sharp.test-d.ts +++ b/test/types/sharp.test-d.ts @@ -418,6 +418,7 @@ sharp({ channels: 4, height: 25000, width: 25000, + pageHeight: 1000, }, limitInputPixels: false, }) @@ -734,6 +735,13 @@ sharp({ svg: { stylesheet: 'test' }}); sharp({ svg: { highBitdepth: true }}); sharp({ svg: { highBitdepth: false }}); +// Raw input options +const raw: sharp.Raw = { width: 1, height: 1, channels: 3 }; +sharp({ raw }); +sharp({ raw: { ...raw, premultiplied: true } }); +sharp({ raw: { ...raw, premultiplied: false } }); +sharp({ raw: { ...raw, pageHeight: 1 } }); + sharp({ autoOrient: true }); sharp({ autoOrient: false }); sharp().autoOrient(); diff --git a/test/unit/noise.js b/test/unit/noise.js index bdd6bbbc..ac3a63db 100644 --- a/test/unit/noise.js +++ b/test/unit/noise.js @@ -173,6 +173,26 @@ describe('Gaussian noise', function () { }); }); + it('animated noise', async () => { + const gif = await sharp({ + create: { + width: 16, + height: 64, + pageHeight: 16, + channels: 3, + noise: { type: 'gaussian' } + } + }) + .gif() + .toBuffer(); + + const { width, height, pages, delay } = await sharp(gif).metadata(); + assert.strictEqual(width, 16); + assert.strictEqual(height, 16); + assert.strictEqual(pages, 4); + assert.strictEqual(delay.length, 4); + }); + it('no create object properties specified', function () { assert.throws(function () { sharp({ @@ -259,4 +279,29 @@ describe('Gaussian noise', function () { }); }); }); + + it('Invalid pageHeight', () => { + const create = { + width: 8, + height: 8, + channels: 4, + noise: { type: 'gaussian' } + }; + assert.throws( + () => sharp({ create: { ...create, pageHeight: 'zoinks' } }), + /Expected positive integer for create\.pageHeight but received zoinks of type string/ + ); + assert.throws( + () => sharp({ create: { ...create, pageHeight: -1 } }), + /Expected positive integer for create\.pageHeight but received -1 of type number/ + ); + assert.throws( + () => sharp({ create: { ...create, pageHeight: 9 } }), + /Expected positive integer for create\.pageHeight but received 9 of type number/ + ); + assert.throws( + () => sharp({ create: { ...create, pageHeight: 3 } }), + /Expected create\.height 8 to be a multiple of create\.pageHeight 3/ + ); + }); }); diff --git a/test/unit/raw.js b/test/unit/raw.js index 7ad24012..9a01d0f0 100644 --- a/test/unit/raw.js +++ b/test/unit/raw.js @@ -55,6 +55,35 @@ describe('Raw pixel data', function () { }); }); + it('Invalid premultiplied', () => { + assert.throws( + () => sharp({ raw: { width: 1, height: 1, channels: 4, premultiplied: 'zoinks' } }), + /Expected boolean for raw\.premultiplied but received zoinks of type string/ + ); + }); + + it('Invalid pageHeight', () => { + const width = 8; + const height = 8; + const channels = 4; + assert.throws( + () => sharp({ raw: { width, height, channels, pageHeight: 'zoinks' } }), + /Expected positive integer for raw\.pageHeight but received zoinks of type string/ + ); + assert.throws( + () => sharp({ raw: { width, height, channels, pageHeight: -1 } }), + /Expected positive integer for raw\.pageHeight but received -1 of type number/ + ); + assert.throws( + () => sharp({ raw: { width, height, channels, pageHeight: 9 } }), + /Expected positive integer for raw\.pageHeight but received 9 of type number/ + ); + assert.throws( + () => sharp({ raw: { width, height, channels, pageHeight: 3 } }), + /Expected raw\.height 8 to be a multiple of raw\.pageHeight 3/ + ); + }); + it('RGB', function (done) { // Convert to raw pixel data sharp(fixtures.inputJpg) @@ -285,6 +314,23 @@ describe('Raw pixel data', function () { } }); + it('Animated', async () => { + const gif = await sharp( + Buffer.alloc(8), + { raw: { width: 1, height: 2, channels: 4, pageHeight: 1 }, animated: true } + ) + .gif({ keepDuplicateFrames: true }) + .toBuffer(); + + console.log(await sharp(gif).metadata()); + + const { width, height, pages, delay } = await sharp(gif).metadata(); + assert.strictEqual(width, 1); + assert.strictEqual(height, 1); + assert.strictEqual(pages, 2); + assert.strictEqual(delay.length, 2); + }); + describe('16-bit roundtrip', () => { it('grey', async () => { const grey = 42000;