From 5ab9168813565489df7d0cef75db82a1ce3b0d8d Mon Sep 17 00:00:00 2001 From: Lovell Fuller Date: Fri, 7 Feb 2025 13:53:27 +0000 Subject: [PATCH] Add support for input array to join or animate #1580 --- docs/src/content/docs/api-constructor.md | 25 ++++- docs/src/content/docs/changelog.md | 3 + lib/constructor.js | 25 ++++- lib/index.d.ts | 48 ++++++--- lib/input.js | 69 +++++++++++- src/common.cc | 24 ++++- src/common.h | 14 ++- src/pipeline.cc | 43 +++++++- src/pipeline.h | 1 + test/fixtures/expected/join2x2.png | Bin 0 -> 9220 bytes test/types/sharp.test-d.ts | 16 +++ test/unit/join.js | 129 +++++++++++++++++++++++ 12 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 test/fixtures/expected/join2x2.png create mode 100644 test/unit/join.js diff --git a/docs/src/content/docs/api-constructor.md b/docs/src/content/docs/api-constructor.md index 7039db50a..8010b58ba 100644 --- a/docs/src/content/docs/api-constructor.md +++ b/docs/src/content/docs/api-constructor.md @@ -33,7 +33,7 @@ where the overall height is the `pageHeight` multiplied by the number of `pages` | Param | Type | Default | Description | | --- | --- | --- | --- | -| [input] | Buffer \| ArrayBuffer \| Uint8Array \| Uint8ClampedArray \| Int8Array \| Uint16Array \| Int16Array \| Uint32Array \| Int32Array \| Float32Array \| Float64Array \| string | | if present, can be a Buffer / ArrayBuffer / Uint8Array / Uint8ClampedArray containing JPEG, PNG, WebP, AVIF, GIF, SVG or TIFF image data, or a TypedArray containing raw pixel image data, or a String containing the filesystem path to an JPEG, PNG, WebP, AVIF, GIF, SVG or TIFF image file. JPEG, PNG, WebP, AVIF, GIF, SVG, TIFF or raw pixel image data can be streamed into the object when not present. | +| [input] | Buffer \| ArrayBuffer \| Uint8Array \| Uint8ClampedArray \| Int8Array \| Uint16Array \| Int16Array \| Uint32Array \| Int32Array \| Float32Array \| Float64Array \| string \| Array | | if present, can be a Buffer / ArrayBuffer / Uint8Array / Uint8ClampedArray containing JPEG, PNG, WebP, AVIF, GIF, SVG or TIFF image data, or a TypedArray containing raw pixel image data, or a String containing the filesystem path to an JPEG, PNG, WebP, AVIF, GIF, SVG or TIFF image file. An array of inputs can be provided, and these will be joined together. JPEG, PNG, WebP, AVIF, GIF, SVG, TIFF or raw pixel image data can be streamed into the object when not present. | | [options] | Object | | if present, is an Object with optional attributes. | | [options.failOn] | string | "'warning'" | When to abort processing of invalid pixel data, one of (in order of sensitivity, least to most): 'none', 'truncated', 'error', 'warning'. Higher levels imply lower levels. Invalid metadata will always abort. | | [options.limitInputPixels] | number \| boolean | 268402689 | Do not process input images where the number of pixels (width x height) exceeds this limit. Assumes image dimensions contained in the input metadata can be trusted. An integral Number of pixels, zero or false to remove limit, true to use default limit of 268402689 (0x3FFF x 0x3FFF). | @@ -74,6 +74,13 @@ where the overall height is the `pageHeight` multiplied by the number of `pages` | [options.text.rgba] | boolean | false | set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for pango markup features like `Red!`. | | [options.text.spacing] | number | 0 | text line height in points. Will use the font line height if none is specified. | | [options.text.wrap] | string | "'word'" | word wrapping style when width is provided, one of: 'word', 'char', 'word-char' (prefer word, fallback to char) or 'none'. | +| [options.join] | Object | | describes how an array of input images should be joined. | +| [options.join.across] | number | 1 | number of images to join horizontally. | +| [options.join.animated] | boolean | false | set this to `true` to join the images as an animated image. | +| [options.join.shim] | number | 0 | number of pixels to insert between joined images. | +| [options.join.background] | string \| Object | | parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. | +| [options.join.halign] | string | "'left'" | horizontal alignment style for images joined horizontally (`'left'`, `'centre'`, `'center'`, `'right'`). | +| [options.join.valign] | string | "'top'" | vertical alignment style for images joined vertically (`'top'`, `'centre'`, `'center'`, `'bottom'`). | **Example** ```js @@ -173,6 +180,22 @@ await sharp({ } }).toFile('text_rgba.png'); ``` +**Example** +```js +// Join four input images as a 2x2 grid with a 4 pixel gutter +const data = await sharp( + [image1, image2, image3, image4], + { join: { across: 2, shim: 4 } } +).toBuffer(); +``` +**Example** +```js +// Generate a two-frame animated image from emoji +const images = ['😀', '😛'].map(text => ({ + text: { text, width: 64, height: 64, channels: 4, rgba: true } +})); +await sharp(images, { join: { animated: true } }).toFile('out.gif'); +``` ## clone diff --git a/docs/src/content/docs/changelog.md b/docs/src/content/docs/changelog.md index 41f2ab374..ac83395b8 100644 --- a/docs/src/content/docs/changelog.md +++ b/docs/src/content/docs/changelog.md @@ -8,6 +8,9 @@ Requires libvips v8.16.0 ### v0.34.0 - TBD +* Breaking: Support array of input images to be joined or animated. + [#1580](https://github.com/lovell/sharp/issues/1580) + * Breaking: Support `info.size` on wide-character systems via upgrade to C++17. [#3943](https://github.com/lovell/sharp/issues/3943) diff --git a/lib/constructor.js b/lib/constructor.js index 7b9c96734..7290d3688 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -121,10 +121,25 @@ const debuglog = util.debuglog('sharp'); * } * }).toFile('text_rgba.png'); * - * @param {(Buffer|ArrayBuffer|Uint8Array|Uint8ClampedArray|Int8Array|Uint16Array|Int16Array|Uint32Array|Int32Array|Float32Array|Float64Array|string)} [input] - if present, can be + * @example + * // Join four input images as a 2x2 grid with a 4 pixel gutter + * const data = await sharp( + * [image1, image2, image3, image4], + * { join: { across: 2, shim: 4 } } + * ).toBuffer(); + * + * @example + * // Generate a two-frame animated image from emoji + * const images = ['😀', '😛'].map(text => ({ + * text: { text, width: 64, height: 64, channels: 4, rgba: true } + * })); + * await sharp(images, { join: { animated: true } }).toFile('out.gif'); + * + * @param {(Buffer|ArrayBuffer|Uint8Array|Uint8ClampedArray|Int8Array|Uint16Array|Int16Array|Uint32Array|Int32Array|Float32Array|Float64Array|string|Array)} [input] - if present, can be * a Buffer / ArrayBuffer / Uint8Array / Uint8ClampedArray containing JPEG, PNG, WebP, AVIF, GIF, SVG or TIFF image data, or * a TypedArray containing raw pixel image data, or * a String containing the filesystem path to an JPEG, PNG, WebP, AVIF, GIF, SVG or TIFF image file. + * An array of inputs can be provided, and these will be joined together. * JPEG, PNG, WebP, AVIF, GIF, SVG, TIFF or raw pixel image data can be streamed into the object when not present. * @param {Object} [options] - if present, is an Object with optional attributes. * @param {string} [options.failOn='warning'] - When to abort processing of invalid pixel data, one of (in order of sensitivity, least to most): 'none', 'truncated', 'error', 'warning'. Higher levels imply lower levels. Invalid metadata will always abort. @@ -169,6 +184,14 @@ const debuglog = util.debuglog('sharp'); * @param {boolean} [options.text.rgba=false] - set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for pango markup features like `Red!`. * @param {number} [options.text.spacing=0] - text line height in points. Will use the font line height if none is specified. * @param {string} [options.text.wrap='word'] - word wrapping style when width is provided, one of: 'word', 'char', 'word-char' (prefer word, fallback to char) or 'none'. + * @param {Object} [options.join] - describes how an array of input images should be joined. + * @param {number} [options.join.across=1] - number of images to join horizontally. + * @param {boolean} [options.join.animated=false] - set this to `true` to join the images as an animated image. + * @param {number} [options.join.shim=0] - number of pixels to insert between joined images. + * @param {string|Object} [options.join.background] - parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. + * @param {string} [options.join.halign='left'] - horizontal alignment style for images joined horizontally (`'left'`, `'centre'`, `'center'`, `'right'`). + * @param {string} [options.join.valign='top'] - vertical alignment style for images joined vertically (`'top'`, `'centre'`, `'center'`, `'bottom'`). + * * @returns {Sharp} * @throws {Error} Invalid parameters */ diff --git a/lib/index.d.ts b/lib/index.d.ts index 05d6a2b53..fd1290e89 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -40,19 +40,7 @@ import { Duplex } from 'stream'; */ declare function sharp(options?: sharp.SharpOptions): sharp.Sharp; declare function sharp( - input?: - | Buffer - | ArrayBuffer - | Uint8Array - | Uint8ClampedArray - | Int8Array - | Uint16Array - | Int16Array - | Uint32Array - | Int32Array - | Float32Array - | Float64Array - | string, + input?: sharp.SharpInput | Array, options?: sharp.SharpOptions, ): sharp.Sharp; @@ -945,6 +933,19 @@ declare namespace sharp { //#endregion } + type SharpInput = Buffer + | ArrayBuffer + | Uint8Array + | Uint8ClampedArray + | Int8Array + | Uint16Array + | Int16Array + | Uint32Array + | Int32Array + | Float32Array + | Float64Array + | string; + interface SharpOptions { /** * Auto-orient based on the EXIF `Orientation` tag, if present. @@ -998,6 +999,8 @@ declare namespace sharp { create?: Create | undefined; /** Describes a new text image to be created. */ text?: CreateText | undefined; + /** Describes how array of input images should be joined. */ + join?: Join | undefined; } interface CacheOptions { @@ -1078,6 +1081,21 @@ declare namespace sharp { wrap?: TextWrap; } + interface Join { + /** Number of images per row. */ + across?: number | undefined; + /** Treat input as frames of an animated image. */ + animated?: boolean | undefined; + /** Space between images, in pixels. */ + shim?: number | undefined; + /** Background colour. */ + background?: Colour | Color | undefined; + /** Horizontal alignment. */ + halign?: HorizontalAlignment | undefined; + /** Vertical alignment. */ + valign?: VerticalAlignment | undefined; + } + interface ExifDir { [k: string]: string; } @@ -1716,6 +1734,10 @@ declare namespace sharp { type TextWrap = 'word' | 'char' | 'word-char' | 'none'; + type HorizontalAlignment = 'left' | 'centre' | 'center' | 'right'; + + type VerticalAlignment = 'top' | 'centre' | 'center' | 'bottom'; + type TileContainer = 'fs' | 'zip'; type TileLayout = 'dz' | 'iiif' | 'iiif3' | 'zoomify' | 'google'; diff --git a/lib/input.js b/lib/input.js index 74fac7138..4ef9f94dd 100644 --- a/lib/input.js +++ b/lib/input.js @@ -14,9 +14,13 @@ const sharp = require('./sharp'); */ const align = { left: 'low', + top: 'low', + low: 'low', center: 'centre', centre: 'centre', - right: 'high' + right: 'high', + bottom: 'high', + high: 'high' }; /** @@ -72,6 +76,18 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { } else if (!is.defined(input) && !is.defined(inputOptions) && is.object(containerOptions) && containerOptions.allowStream) { // Stream without options inputDescriptor.buffer = []; + } else if (Array.isArray(input)) { + if (input.length > 1) { + // Join images together + if (!this.options.joining) { + this.options.joining = true; + this.options.join = input.map(i => this._createInputDescriptor(i)); + } else { + throw new Error('Recursive join is unsupported'); + } + } else { + throw new Error('Expected at least two images to join'); + } } else { throw new Error(`Unsupported input '${input}' of type ${typeof input}${ is.defined(inputOptions) ? ` when also providing options of type ${typeof inputOptions}` : '' @@ -369,6 +385,57 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { throw new Error('Expected a valid string to create an image with text.'); } } + // Join images together + if (is.defined(inputOptions.join)) { + if (is.defined(this.options.join)) { + if (is.defined(inputOptions.join.animated)) { + if (is.bool(inputOptions.join.animated)) { + inputDescriptor.joinAnimated = inputOptions.join.animated; + } else { + throw is.invalidParameterError('join.animated', 'boolean', inputOptions.join.animated); + } + } + if (is.defined(inputOptions.join.across)) { + if (is.integer(inputOptions.join.across) && is.inRange(inputOptions.join.across, 1, 1000000)) { + inputDescriptor.joinAcross = inputOptions.join.across; + } else { + throw is.invalidParameterError('join.across', 'integer between 1 and 100000', inputOptions.join.across); + } + } + if (is.defined(inputOptions.join.shim)) { + if (is.integer(inputOptions.join.shim) && is.inRange(inputOptions.join.shim, 0, 1000000)) { + inputDescriptor.joinShim = inputOptions.join.shim; + } else { + throw is.invalidParameterError('join.shim', 'integer between 0 and 100000', inputOptions.join.shim); + } + } + if (is.defined(inputOptions.join.background)) { + const background = color(inputOptions.join.background); + inputDescriptor.joinBackground = [ + background.red(), + background.green(), + background.blue(), + Math.round(background.alpha() * 255) + ]; + } + if (is.defined(inputOptions.join.halign)) { + if (is.string(inputOptions.join.halign) && is.string(this.constructor.align[inputOptions.join.halign])) { + inputDescriptor.joinHalign = this.constructor.align[inputOptions.join.halign]; + } else { + throw is.invalidParameterError('join.halign', 'valid alignment', inputOptions.join.halign); + } + } + if (is.defined(inputOptions.join.valign)) { + if (is.string(inputOptions.join.valign) && is.string(this.constructor.align[inputOptions.join.valign])) { + inputDescriptor.joinValign = this.constructor.align[inputOptions.join.valign]; + } else { + throw is.invalidParameterError('join.valign', 'valid alignment', inputOptions.join.valign); + } + } + } else { + throw new Error('Expected input to be an array of images to join'); + } + } } else if (is.defined(inputOptions)) { throw new Error('Invalid input options ' + inputOptions); } diff --git a/src/common.cc b/src/common.cc index cd556e0b3..528ef7c7f 100644 --- a/src/common.cc +++ b/src/common.cc @@ -160,10 +160,30 @@ namespace sharp { descriptor->textWrap = AttrAsEnum(input, "textWrap", VIPS_TYPE_TEXT_WRAP); } } + // Join images together + if (HasAttr(input, "joinAnimated")) { + descriptor->joinAnimated = AttrAsBool(input, "joinAnimated"); + } + if (HasAttr(input, "joinAcross")) { + descriptor->joinAcross = AttrAsUint32(input, "joinAcross"); + } + if (HasAttr(input, "joinShim")) { + descriptor->joinShim = AttrAsUint32(input, "joinShim"); + } + if (HasAttr(input, "joinBackground")) { + descriptor->joinBackground = AttrAsVectorOfDouble(input, "joinBackground"); + } + if (HasAttr(input, "joinHalign")) { + descriptor->joinHalign = AttrAsEnum(input, "joinHalign", VIPS_TYPE_ALIGN); + } + if (HasAttr(input, "joinValign")) { + descriptor->joinValign = AttrAsEnum(input, "joinValign", VIPS_TYPE_ALIGN); + } // Limit input images to a given number of pixels, where pixels = width * height descriptor->limitInputPixels = static_cast(AttrAsInt64(input, "limitInputPixels")); - // Allow switch from random to sequential access - descriptor->access = AttrAsBool(input, "sequentialRead") ? VIPS_ACCESS_SEQUENTIAL : VIPS_ACCESS_RANDOM; + if (HasAttr(input, "access")) { + descriptor->access = AttrAsBool(input, "sequentialRead") ? VIPS_ACCESS_SEQUENTIAL : VIPS_ACCESS_RANDOM; + } // Remove safety features and allow unlimited input descriptor->unlimited = AttrAsBool(input, "unlimited"); // Use the EXIF orientation to auto orient the image diff --git a/src/common.h b/src/common.h index 63eade9fe..3e0f7884e 100644 --- a/src/common.h +++ b/src/common.h @@ -71,6 +71,12 @@ namespace sharp { int textSpacing; VipsTextWrap textWrap; int textAutofitDpi; + bool joinAnimated; + int joinAcross; + int joinShim; + std::vector joinBackground; + VipsAlign joinHalign; + VipsAlign joinValign; std::vector pdfBackground; InputDescriptor(): @@ -79,7 +85,7 @@ namespace sharp { failOn(VIPS_FAIL_ON_WARNING), limitInputPixels(0x3FFF * 0x3FFF), unlimited(false), - access(VIPS_ACCESS_RANDOM), + access(VIPS_ACCESS_SEQUENTIAL), bufferLength(0), isBuffer(false), density(72.0), @@ -108,6 +114,12 @@ namespace sharp { textSpacing(0), textWrap(VIPS_TEXT_WRAP_WORD), textAutofitDpi(0), + joinAnimated(false), + joinAcross(1), + joinShim(0), + joinBackground{ 0.0, 0.0, 0.0, 255.0 }, + joinHalign(VIPS_ALIGN_LOW), + joinValign(VIPS_ALIGN_LOW), pdfBackground{ 255.0, 255.0, 255.0, 255.0 } {} }; diff --git a/src/pipeline.cc b/src/pipeline.cc index 3a7bf0d0a..4495f2bfb 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -42,7 +42,39 @@ class PipelineWorker : public Napi::AsyncWorker { // Open input vips::VImage image; sharp::ImageType inputImageType; - std::tie(image, inputImageType) = sharp::OpenInput(baton->input); + if (baton->join.empty()) { + std::tie(image, inputImageType) = sharp::OpenInput(baton->input); + } else { + std::vector images; + bool hasAlpha = false; + for (auto &join : baton->join) { + std::tie(image, inputImageType) = sharp::OpenInput(join); + image = sharp::EnsureColourspace(image, baton->colourspacePipeline); + images.push_back(image); + hasAlpha |= sharp::HasAlpha(image); + } + if (hasAlpha) { + for (auto &image : images) { + if (!sharp::HasAlpha(image)) { + image = sharp::EnsureAlpha(image, 1); + } + } + } else { + baton->input->joinBackground.pop_back(); + } + inputImageType = sharp::ImageType::PNG; + image = VImage::arrayjoin(images, VImage::option() + ->set("across", baton->input->joinAcross) + ->set("shim", baton->input->joinShim) + ->set("background", baton->input->joinBackground) + ->set("halign", baton->input->joinHalign) + ->set("valign", baton->input->joinValign)); + if (baton->input->joinAnimated) { + image = image.copy(); + image.set(VIPS_META_N_PAGES, static_cast(images.size())); + image.set(VIPS_META_PAGE_HEIGHT, static_cast(image.height() / images.size())); + } + } VipsAccess access = baton->input->access; image = sharp::EnsureColourspace(image, baton->colourspacePipeline); @@ -1069,6 +1101,7 @@ class PipelineWorker : public Napi::AsyncWorker { // Unsupported output format (baton->err).append("Unsupported output format "); if (baton->formatOut == "input") { + (baton->err).append("when trying to match input format of "); (baton->err).append(ImageTypeId(inputImageType)); } else { (baton->err).append(baton->formatOut); @@ -1495,6 +1528,14 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { // Input baton->input = sharp::CreateInputDescriptor(options.Get("input").As()); + // Join images together + if (sharp::HasAttr(options, "join")) { + Napi::Array join = options.Get("join").As(); + for (unsigned int i = 0; i < join.Length(); i++) { + baton->join.push_back( + sharp::CreateInputDescriptor(join.Get(i).As())); + } + } // Extract image options baton->topOffsetPre = sharp::AttrAsInt32(options, "topOffsetPre"); baton->leftOffsetPre = sharp::AttrAsInt32(options, "leftOffsetPre"); diff --git a/src/pipeline.h b/src/pipeline.h index 530da5b67..be53d6ade 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -39,6 +39,7 @@ struct Composite { struct PipelineBaton { sharp::InputDescriptor *input; + std::vector join; std::string formatOut; std::string fileOut; void *bufferOut; diff --git a/test/fixtures/expected/join2x2.png b/test/fixtures/expected/join2x2.png new file mode 100644 index 0000000000000000000000000000000000000000..533df12846a9630a7aa567623d7837b03a18bac0 GIT binary patch literal 9220 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 literal 0 HcmV?d00001 diff --git a/test/types/sharp.test-d.ts b/test/types/sharp.test-d.ts index 719432c94..93b421bae 100644 --- a/test/types/sharp.test-d.ts +++ b/test/types/sharp.test-d.ts @@ -724,3 +724,19 @@ sharp({ pdfBackground: color }); sharp({ autoOrient: true }); sharp({ autoOrient: false }); sharp().autoOrient(); + +sharp([input, input]); +sharp([input, input], { + join: { + animated: true + } +}); +sharp([input, input], { + join: { + across: 2, + shim: 5, + background: colour, + halign: 'centre', + valign: 'bottom' + } +}); diff --git a/test/unit/join.js b/test/unit/join.js new file mode 100644 index 000000000..0e14e97e4 --- /dev/null +++ b/test/unit/join.js @@ -0,0 +1,129 @@ +// Copyright 2013 Lovell Fuller and others. +// SPDX-License-Identifier: Apache-2.0 + +'use strict'; + +const assert = require('assert'); + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +describe('Join input images together', function () { + it('Join two images horizontally', async () => { + const data = await sharp([ + fixtures.inputPngPalette, + { create: { width: 68, height: 68, channels: 3, background: 'green' } } + ], { join: { across: 2 } }).toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual(metadata.format, 'png'); + assert.strictEqual(metadata.width, 136); + assert.strictEqual(metadata.height, 68); + assert.strictEqual(metadata.space, 'srgb'); + assert.strictEqual(metadata.channels, 3); + assert.strictEqual(metadata.hasAlpha, false); + }); + + it('Join two images vertically with shim and alpha channel', async () => { + const data = await sharp([ + fixtures.inputPngPalette, + { create: { width: 68, height: 68, channels: 4, background: 'green' } } + ], { join: { across: 1, shim: 8 } }).toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual(metadata.format, 'png'); + assert.strictEqual(metadata.width, 68); + assert.strictEqual(metadata.height, 144); + assert.strictEqual(metadata.space, 'srgb'); + assert.strictEqual(metadata.channels, 4); + assert.strictEqual(metadata.hasAlpha, true); + }); + + it('Join four images in 2x2 grid, with centre alignment', async () => { + const output = fixtures.path('output.join2x2.png'); + const info = await sharp([ + fixtures.inputPngPalette, + { create: { width: 128, height: 128, channels: 3, background: 'green' } }, + { create: { width: 128, height: 128, channels: 3, background: 'red' } }, + fixtures.inputPngPalette + ], { join: { across: 2, halign: 'centre', valign: 'centre', background: 'blue' } }) + .toFile(output); + + fixtures.assertMaxColourDistance(output, fixtures.expected('join2x2.png')); + + assert.strictEqual(info.format, 'png'); + assert.strictEqual(info.width, 256); + assert.strictEqual(info.height, 256); + assert.strictEqual(info.channels, 3); + }); + + it('Join two images as animation', async () => { + const data = await sharp([ + fixtures.inputPngPalette, + { create: { width: 68, height: 68, channels: 3, background: 'green' } } + ], { join: { animated: true } }).gif().toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual(metadata.format, 'gif'); + assert.strictEqual(metadata.width, 68); + assert.strictEqual(metadata.height, 68); + assert.strictEqual(metadata.pages, 2); + }); + + it('Empty array of inputs throws', () => { + assert.throws( + () => sharp([]), + /Expected at least two images to join/ + ); + }); + it('Attempt to recursively join throws', () => { + assert.throws( + () => sharp([fixtures.inputJpg, [fixtures.inputJpg, fixtures.inputJpg]]), + /Recursive join is unsupported/ + ); + }); + it('Attempt to set join props on non-array input throws', () => { + assert.throws( + () => sharp(fixtures.inputJpg, { join: { across: 2 } }), + /Expected input to be an array of images to join/ + ); + }); + it('Invalid animated throws', () => { + assert.throws( + () => sharp([fixtures.inputJpg, fixtures.inputJpg], { join: { animated: 'fail' } }), + /Expected boolean for join.animated but received fail of type string/ + ); + }); + it('Invalid across throws', () => { + assert.throws( + () => sharp([fixtures.inputJpg, fixtures.inputJpg], { join: { across: 'fail' } }), + /Expected integer between 1 and 100000 for join.across but received fail of type string/ + ); + assert.throws( + () => sharp([fixtures.inputJpg, fixtures.inputJpg], { join: { across: 0 } }), + /Expected integer between 1 and 100000 for join.across but received 0 of type number/ + ); + }); + it('Invalid shim throws', () => { + assert.throws( + () => sharp([fixtures.inputJpg, fixtures.inputJpg], { join: { shim: 'fail' } }), + /Expected integer between 0 and 100000 for join.shim but received fail of type string/ + ); + assert.throws( + () => sharp([fixtures.inputJpg, fixtures.inputJpg], { join: { shim: -1 } }), + /Expected integer between 0 and 100000 for join.shim but received -1 of type number/ + ); + }); + it('Invalid halign', () => { + assert.throws( + () => sharp([fixtures.inputJpg, fixtures.inputJpg], { join: { halign: 'fail' } }), + /Expected valid alignment for join.halign but received fail of type string/ + ); + }); + it('Invalid valign', () => { + assert.throws( + () => sharp([fixtures.inputJpg, fixtures.inputJpg], { join: { valign: 'fail' } }), + /Expected valid alignment for join.valign but received fail of type string/ + ); + }); +});