diff --git a/.gitignore b/.gitignore index 82f73b3f0..c8617e93d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ package-lock.json .astro docs/dist release-notes.md +.cache +compile_commands.json diff --git a/docs/src/content/docs/api-resize.md b/docs/src/content/docs/api-resize.md index 6452c3455..364b1b5a7 100644 --- a/docs/src/content/docs/api-resize.md +++ b/docs/src/content/docs/api-resize.md @@ -283,6 +283,7 @@ The `info` response Object will contain `trimOffsetLeft` and `trimOffsetTop` pro | [options] | Object | | | | [options.background] | string \| Object | "'top-left pixel'" | Background colour, parsed by the [color](https://www.npmjs.org/package/color) module, defaults to that of the top-left pixel. | | [options.threshold] | number | 10 | Allowed difference from the above colour, a positive number. | +| [options.margin] | number | 0 | Applies margin in pixels to trim edges leaving extra space around trimmed content. | | [options.lineArt] | boolean | false | Does the input more closely resemble line art (e.g. vector) rather than being photographic? | **Example** @@ -320,4 +321,13 @@ const output = await sharp(input) threshold: 42, }) .toBuffer(); +``` +**Example** +```js +// Trim image but leave extra space around its content–rectangle of interest. +const output = await sharp(input) + .trim({ + margin: 10 + }) + .toBuffer(); ``` \ No newline at end of file diff --git a/lib/constructor.js b/lib/constructor.js index 9aac8105c..fdb267738 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -278,6 +278,7 @@ const Sharp = function (input, options) { trimBackground: [], trimThreshold: -1, trimLineArt: false, + trimMargin: 0, dilateWidth: 0, erodeWidth: 0, gamma: 0, diff --git a/lib/index.d.ts b/lib/index.d.ts index 13714c947..09a825f40 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1608,6 +1608,8 @@ declare namespace sharp { threshold?: number | undefined; /** Does the input more closely resemble line art (e.g. vector) rather than being photographic? (optional, default false) */ lineArt?: boolean | undefined; + /** Applies margin in pixels to trim edges leaving extra space around trimmed content. (optional, default 0) */ + margin?: number | undefined; } interface RawOptions { diff --git a/lib/resize.js b/lib/resize.js index 544fbba3a..c4d27af5c 100644 --- a/lib/resize.js +++ b/lib/resize.js @@ -540,9 +540,18 @@ function extract (options) { * }) * .toBuffer(); * + * @example + * // Trim image but leave extra space around its content–rectangle of interest. + * const output = await sharp(input) + * .trim({ + * margin: 10 + * }) + * .toBuffer(); + * * @param {Object} [options] * @param {string|Object} [options.background='top-left pixel'] - Background colour, parsed by the [color](https://www.npmjs.org/package/color) module, defaults to that of the top-left pixel. * @param {number} [options.threshold=10] - Allowed difference from the above colour, a positive number. + * @param {number} [options.margin=0] - Applies margin in pixels to trim edges leaving extra space around trimmed content. * @param {boolean} [options.lineArt=false] - Does the input more closely resemble line art (e.g. vector) rather than being photographic? * @returns {Sharp} * @throws {Error} Invalid parameters @@ -564,6 +573,13 @@ function trim (options) { if (is.defined(options.lineArt)) { this._setBooleanOption('trimLineArt', options.lineArt); } + if (is.defined(options.margin)) { + if (is.number(options.margin) && options.margin >= 0) { + this.options.trimMargin = options.margin; + } else { + throw is.invalidParameterError('margin', 'positive integer', options.margin); + } + } } else { throw is.invalidParameterError('trim', 'object', options); } diff --git a/package.json b/package.json index 0c0d00988..88f09cec5 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,8 @@ "Lachlan Newman ", "Dennis Beatty ", "Ingvar Stepanyan ", - "Don Denton " + "Don Denton ", + "Dmytro Tiapukhin " ], "scripts": { "build": "node install/build.js", diff --git a/src/operations.cc b/src/operations.cc index daeba5ab4..94fea1e93 100644 --- a/src/operations.cc +++ b/src/operations.cc @@ -285,7 +285,7 @@ namespace sharp { /* Trim an image */ - VImage Trim(VImage image, std::vector background, double threshold, bool const lineArt) { + VImage Trim(VImage image, std::vector background, double threshold, bool const lineArt, int const margin) { if (image.width() < 3 && image.height() < 3) { throw VError("Image to trim must be at least 3x3 pixels"); } @@ -304,6 +304,13 @@ namespace sharp { } else { background.resize(image.bands()); } + auto applyMargin = [&](int left, int top, int width, int height) { + int const marginLeft = std::max(0, left - margin); + int const marginTop = std::max(0, top - margin); + int const marginWidth = std::min(image.width(), left + width + margin) - marginLeft; + int const marginHeight = std::min(image.height(), top + height + margin) - marginTop; + return std::make_tuple(marginLeft, marginTop, marginWidth, marginHeight); + }; int left, top, width, height; left = image.find_trim(&top, &width, &height, VImage::option() ->set("background", background) @@ -324,15 +331,22 @@ namespace sharp { int const topB = std::min(top, topA); int const widthB = std::max(left + width, leftA + widthA) - leftB; int const heightB = std::max(top + height, topA + heightA) - topB; - return image.extract_area(leftB, topB, widthB, heightB); + + // Combined bounding box + margin + auto [ml, mt, mw, mh] = applyMargin(leftB, topB, widthB, heightB); + return image.extract_area(ml, mt, mw, mh); } else { // Use alpha only - return image.extract_area(leftA, topA, widthA, heightA); + // Bounding box + margin + auto [ml, mt, mw, mh] = applyMargin(leftA, topA, widthA, heightA); + return image.extract_area(ml, mt, mw, mh); } } } if (width > 0 && height > 0) { - return image.extract_area(left, top, width, height); + // Bounding box + margin + auto [ml, mt, mw, mh] = applyMargin(left, top, width, height); + return image.extract_area(ml, mt, mw, mh); } return image; } diff --git a/src/operations.h b/src/operations.h index c281c02cd..b9699bb93 100644 --- a/src/operations.h +++ b/src/operations.h @@ -82,7 +82,7 @@ namespace sharp { /* Trim an image */ - VImage Trim(VImage image, std::vector background, double threshold, bool const lineArt); + VImage Trim(VImage image, std::vector background, double threshold, bool const lineArt, int const margin); /* * Linear adjustment (a * in + b) diff --git a/src/pipeline.cc b/src/pipeline.cc index 5f0a3bb0e..c6dff380c 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -153,7 +153,7 @@ class PipelineWorker : public Napi::AsyncWorker { if (baton->trimThreshold >= 0.0) { MultiPageUnsupported(nPages, "Trim"); image = sharp::StaySequential(image); - image = sharp::Trim(image, baton->trimBackground, baton->trimThreshold, baton->trimLineArt); + image = sharp::Trim(image, baton->trimBackground, baton->trimThreshold, baton->trimLineArt, baton->trimMargin); baton->trimOffsetLeft = image.xoffset(); baton->trimOffsetTop = image.yoffset(); } @@ -1615,6 +1615,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->trimBackground = sharp::AttrAsVectorOfDouble(options, "trimBackground"); baton->trimThreshold = sharp::AttrAsDouble(options, "trimThreshold"); baton->trimLineArt = sharp::AttrAsBool(options, "trimLineArt"); + baton->trimMargin = sharp::AttrAsUint32(options, "trimMargin"); baton->gamma = sharp::AttrAsDouble(options, "gamma"); baton->gammaOut = sharp::AttrAsDouble(options, "gammaOut"); baton->linearA = sharp::AttrAsVectorOfDouble(options, "linearA"); diff --git a/src/pipeline.h b/src/pipeline.h index ff9465987..8a788245b 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -101,6 +101,7 @@ struct PipelineBaton { bool trimLineArt; int trimOffsetLeft; int trimOffsetTop; + int trimMargin; std::vector linearA; std::vector linearB; int dilateWidth; @@ -281,6 +282,7 @@ struct PipelineBaton { trimLineArt(false), trimOffsetLeft(0), trimOffsetTop(0), + trimMargin(0), linearA{}, linearB{}, dilateWidth(0), diff --git a/test/fixtures/index.js b/test/fixtures/index.js index 2448843e2..75bd1bfb9 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -73,6 +73,7 @@ module.exports = { inputPng: getPath('50020484-00001.png'), // http://c.searspartsdirect.com/lis_png/PLDM/50020484-00001.png inputPngGradients: getPath('gradients-rgb8.png'), + inputPngWithSlightGradientBorder: getPath('slight-gradient-border.png'), // https://pixabay.com/photos/cat-bury-cat-animal-sitting-cat-3038243/ inputPngWithTransparency: getPath('blackbug.png'), // public domain inputPngCompleteTransparency: getPath('full-transparent.png'), inputPngWithGreyAlpha: getPath('grey-8bit-alpha.png'), diff --git a/test/fixtures/slight-gradient-border.png b/test/fixtures/slight-gradient-border.png new file mode 100644 index 000000000..6ca1e8faf Binary files /dev/null and b/test/fixtures/slight-gradient-border.png differ diff --git a/test/types/sharp.test-d.ts b/test/types/sharp.test-d.ts index 9364c08c8..8f9b1c6b1 100644 --- a/test/types/sharp.test-d.ts +++ b/test/types/sharp.test-d.ts @@ -598,7 +598,7 @@ const vertexSplitQuadraticBasisSpline: string = sharp.interpolators.vertexSplitQ // Triming sharp(input).trim({ background: '#000' }).toBuffer(); sharp(input).trim({ threshold: 10, lineArt: true }).toBuffer(); -sharp(input).trim({ background: '#bf1942', threshold: 30 }).toBuffer(); +sharp(input).trim({ background: '#bf1942', threshold: 30, margin: 20 }).toBuffer(); // Text input sharp({ @@ -705,20 +705,20 @@ sharp(input) // https://github.com/lovell/sharp/pull/4048 sharp(input).composite([ { - input: 'image.gif', - animated: true, - limitInputPixels: 536805378, - density: 144, + input: 'image.gif', + animated: true, + limitInputPixels: 536805378, + density: 144, failOn: "warning", autoOrient: true } ]) sharp(input).composite([ { - input: 'image.png', + input: 'image.png', animated: false, - limitInputPixels: 178935126, - density: 72, + limitInputPixels: 178935126, + density: 72, failOn: "truncated" } ]) diff --git a/test/unit/trim.js b/test/unit/trim.js index 99b14a8d9..ea033a922 100644 --- a/test/unit/trim.js +++ b/test/unit/trim.js @@ -222,6 +222,9 @@ describe('Trim borders', () => { }, 'Invalid lineArt': { lineArt: 'fail' + }, + 'Invalid margin': { + margin: -1 } }).forEach(([description, parameter]) => { it(description, () => { @@ -289,4 +292,39 @@ describe('Trim borders', () => { assert.strictEqual(trimOffsetLeft, 0); }); }); + + describe('Margin around content', () => { + it('Should trim complex gradients', async () => { + const { info } = await sharp(fixtures.inputPngGradients) + .trim({ threshold: 50, margin: 100 }).toBuffer({ resolveWithObject: true }); + + const { width, height, trimOffsetTop, trimOffsetLeft } = info; + assert.strictEqual(width, 1000); + assert.strictEqual(height, 443); + assert.strictEqual(trimOffsetTop, -557); + assert.strictEqual(trimOffsetLeft, 0); + }); + + it('Should trim simple gradients', async () => { + const { info } = await sharp(fixtures.inputPngWithSlightGradientBorder) + .trim({ threshold: 70, margin: 50 }).toBuffer({ resolveWithObject: true }); + + const { width, height, trimOffsetTop, trimOffsetLeft } = info; + assert.strictEqual(width, 900); + assert.strictEqual(height, 900); + assert.strictEqual(trimOffsetTop, -50); + assert.strictEqual(trimOffsetLeft, -50); + }); + + it('Should not overflow image bounding box', async () => { + const { info } = await sharp(fixtures.inputPngWithSlightGradientBorder) + .trim({ threshold: 70, margin: 9999999 }).toBuffer({ resolveWithObject: true }); + + const { width, height, trimOffsetTop, trimOffsetLeft } = info; + assert.strictEqual(width, 1000); + assert.strictEqual(height, 1000); + assert.strictEqual(trimOffsetTop, 0); + assert.strictEqual(trimOffsetLeft, 0); + }); + }); });