Add support for input array to join or animate #1580

This commit is contained in:
Lovell Fuller 2025-02-07 13:53:27 +00:00
parent 67ff930535
commit 5ab9168813
12 changed files with 377 additions and 20 deletions

View File

@ -33,7 +33,7 @@ where the overall height is the `pageHeight` multiplied by the number of `pages`
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| [input] | <code>Buffer</code> \| <code>ArrayBuffer</code> \| <code>Uint8Array</code> \| <code>Uint8ClampedArray</code> \| <code>Int8Array</code> \| <code>Uint16Array</code> \| <code>Int16Array</code> \| <code>Uint32Array</code> \| <code>Int32Array</code> \| <code>Float32Array</code> \| <code>Float64Array</code> \| <code>string</code> | | 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] | <code>Buffer</code> \| <code>ArrayBuffer</code> \| <code>Uint8Array</code> \| <code>Uint8ClampedArray</code> \| <code>Int8Array</code> \| <code>Uint16Array</code> \| <code>Int16Array</code> \| <code>Uint32Array</code> \| <code>Int32Array</code> \| <code>Float32Array</code> \| <code>Float64Array</code> \| <code>string</code> \| <code>Array</code> | | 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] | <code>Object</code> | | if present, is an Object with optional attributes. |
| [options.failOn] | <code>string</code> | <code>&quot;&#x27;warning&#x27;&quot;</code> | 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] | <code>number</code> \| <code>boolean</code> | <code>268402689</code> | 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] | <code>boolean</code> | <code>false</code> | set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for pango markup features like `<span foreground="red">Red!</span>`. |
| [options.text.spacing] | <code>number</code> | <code>0</code> | text line height in points. Will use the font line height if none is specified. |
| [options.text.wrap] | <code>string</code> | <code>&quot;&#x27;word&#x27;&quot;</code> | word wrapping style when width is provided, one of: 'word', 'char', 'word-char' (prefer word, fallback to char) or 'none'. |
| [options.join] | <code>Object</code> | | describes how an array of input images should be joined. |
| [options.join.across] | <code>number</code> | <code>1</code> | number of images to join horizontally. |
| [options.join.animated] | <code>boolean</code> | <code>false</code> | set this to `true` to join the images as an animated image. |
| [options.join.shim] | <code>number</code> | <code>0</code> | number of pixels to insert between joined images. |
| [options.join.background] | <code>string</code> \| <code>Object</code> | | parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. |
| [options.join.halign] | <code>string</code> | <code>&quot;&#x27;left&#x27;&quot;</code> | horizontal alignment style for images joined horizontally (`'left'`, `'centre'`, `'center'`, `'right'`). |
| [options.join.valign] | <code>string</code> | <code>&quot;&#x27;top&#x27;&quot;</code> | 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

View File

@ -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)

View File

@ -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 `<span foreground="red">Red!</span>`.
* @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
*/

48
lib/index.d.ts vendored
View File

@ -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<sharp.SharpInput>,
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';

View File

@ -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);
}

View File

@ -160,10 +160,30 @@ namespace sharp {
descriptor->textWrap = AttrAsEnum<VipsTextWrap>(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<VipsAlign>(input, "joinHalign", VIPS_TYPE_ALIGN);
}
if (HasAttr(input, "joinValign")) {
descriptor->joinValign = AttrAsEnum<VipsAlign>(input, "joinValign", VIPS_TYPE_ALIGN);
}
// Limit input images to a given number of pixels, where pixels = width * height
descriptor->limitInputPixels = static_cast<uint64_t>(AttrAsInt64(input, "limitInputPixels"));
// Allow switch from random to sequential access
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

View File

@ -71,6 +71,12 @@ namespace sharp {
int textSpacing;
VipsTextWrap textWrap;
int textAutofitDpi;
bool joinAnimated;
int joinAcross;
int joinShim;
std::vector<double> joinBackground;
VipsAlign joinHalign;
VipsAlign joinValign;
std::vector<double> 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 } {}
};

View File

@ -42,7 +42,39 @@ class PipelineWorker : public Napi::AsyncWorker {
// Open input
vips::VImage image;
sharp::ImageType inputImageType;
if (baton->join.empty()) {
std::tie(image, inputImageType) = sharp::OpenInput(baton->input);
} else {
std::vector<VImage> 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<int>(images.size()));
image.set(VIPS_META_PAGE_HEIGHT, static_cast<int>(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<Napi::Object>());
// Join images together
if (sharp::HasAttr(options, "join")) {
Napi::Array join = options.Get("join").As<Napi::Array>();
for (unsigned int i = 0; i < join.Length(); i++) {
baton->join.push_back(
sharp::CreateInputDescriptor(join.Get(i).As<Napi::Object>()));
}
}
// Extract image options
baton->topOffsetPre = sharp::AttrAsInt32(options, "topOffsetPre");
baton->leftOffsetPre = sharp::AttrAsInt32(options, "leftOffsetPre");

View File

@ -39,6 +39,7 @@ struct Composite {
struct PipelineBaton {
sharp::InputDescriptor *input;
std::vector<sharp::InputDescriptor *> join;
std::string formatOut;
std::string fileOut;
void *bufferOut;

BIN
test/fixtures/expected/join2x2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -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'
}
});

129
test/unit/join.js Normal file
View File

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