diff --git a/index.js b/index.js index f53838bd..93a0a37c 100644 --- a/index.js +++ b/index.js @@ -93,6 +93,8 @@ var Sharp = function(input, options) { overlayFileIn: '', overlayBufferIn: null, overlayGravity: 0, + overlayXOffset : -1, + overlayYOffset : -1, overlayTile: false, overlayCutout: false, // output options @@ -357,35 +359,61 @@ Sharp.prototype.overlayWith = function(overlay, options) { throw new Error('Unsupported overlay ' + typeof overlay); } if (isObject(options)) { - if (typeof options.tile === 'undefined') { - this.options.overlayTile = false; + if(isDefined(options.tile)) { + setTileOption(options.tile, this.options); } - else if (isBoolean(options.tile)) { - this.options.overlayTile = options.tile; - } else { - throw new Error('Invalid Value for tile ' + options.tile + ' Only Boolean Values allowed for overlay.tile.'); + if(isDefined(options.cutout)) { + setCutoutOption(options.cutout, this.options); } - - if (typeof options.cutout === 'undefined') { - this.options.overlayCutout = false; + if(isDefined(options.left) || isDefined(options.top)) { + setOffsetOption(options.top, options.left, this.options); } - else if (isBoolean(options.cutout)) { - this.options.overlayCutout = options.cutout; - } else { - throw new Error('Invalid Value for cutout ' + options.cutout + ' Only Boolean Values allowed for overlay.cutout.'); - } - - if (isInteger(options.gravity) && inRange(options.gravity, 0, 8)) { - this.options.overlayGravity = options.gravity; - } else if (isString(options.gravity) && isInteger(module.exports.gravity[options.gravity])) { - this.options.overlayGravity = module.exports.gravity[options.gravity]; - } else if (isDefined(options.gravity)) { - throw new Error('Unsupported overlay gravity ' + options.gravity); + if (isDefined(options.gravity)) { + setGravityOption(options.gravity, this.options); } } return this; }; +/* + Supporting functions for overlayWith +*/ +function setTileOption(tile, options) { + if(isBoolean(tile)) { + options.overlayTile = tile; + } else { + throw new Error('Invalid Value for tile ' + tile + ' Only Boolean Values allowed for overlay.tile.'); + } +} + +function setCutoutOption(cutout, options) { + if(isBoolean(cutout)) { + options.overlayCutout = cutout; + } else { + throw new Error('Invalid Value for cutout ' + cutout + ' Only Boolean Values allowed for overlay.cutout.'); + } +} + +function setOffsetOption(top, left, options) { + if(isInteger(left) && left >= 0 && isInteger(top) && top >= 0) { + options.overlayXOffset = left; + options.overlayYOffset = top; + } else { + throw new Error('Unsupported top and/or left offset values'); + } +} + +function setGravityOption(gravity, options) { + if(isInteger(gravity) && inRange(gravity, 0, 8)) { + options.overlayGravity = gravity; + } else if (isString(gravity) && isInteger(module.exports.gravity[gravity])) { + options.overlayGravity = module.exports.gravity[gravity]; + } else { + throw new Error('Unsupported overlay gravity ' + gravity); + } +} + + /* Rotate output image by 0, 90, 180 or 270 degrees Auto-rotation based on the EXIF Orientation tag is represented by an angle of -1 diff --git a/src/common.cc b/src/common.cc index cce65eb7..71f163d0 100644 --- a/src/common.cc +++ b/src/common.cc @@ -277,6 +277,40 @@ namespace sharp { return std::make_tuple(left, top); } + /* + Calculate the (left, top) coordinates of the output image + within the input image, applying the given x and y offsets. + */ + std::tuple CalculateCrop(int const inWidth, int const inHeight, + int const outWidth, int const outHeight, int const x, int const y) { + + // default values + int left = 0; + int top = 0; + + // assign only if valid + if(x >= 0 && x < (inWidth - outWidth)) { + left = x; + } else if(x >= (inWidth - outWidth)) { + left = inWidth - outWidth; + } + + if(y >= 0 && y < (inHeight - outHeight)) { + top = y; + } else if(x >= (inHeight - outHeight)) { + top = inHeight - outHeight; + } + + // the resulting left and top could have been outside the image after calculation from bottom/right edges + if(left < 0) { + left = 0; + } + if(top < 0) { + top = 0; + } + + return std::make_tuple(left, top); + } /* Return the image alpha maximum. Useful for combining alpha bands. scRGB images are 0 - 1 for image data, but the alpha is 0 - 255. diff --git a/src/common.h b/src/common.h index c615d3e5..6beeb7ee 100644 --- a/src/common.h +++ b/src/common.h @@ -108,8 +108,16 @@ namespace sharp { std::tuple CalculateCrop(int const inWidth, int const inHeight, int const outWidth, int const outHeight, int const gravity); + /* + Calculate the (left, top) coordinates of the output image + within the input image, applying the given x and y offsets of the output image. + */ + std::tuple CalculateCrop(int const inWidth, int const inHeight, + int const outWidth, int const outHeight, int const x, int const y); + int MaximumImageAlpha(VipsInterpretation interpretation); + } // namespace sharp #endif // SRC_COMMON_H_ diff --git a/src/operations.cc b/src/operations.cc index 0c2f1e65..106c5a04 100644 --- a/src/operations.cc +++ b/src/operations.cc @@ -16,6 +16,49 @@ namespace sharp { Assumes alpha channels are already premultiplied and will be unpremultiplied after. */ VImage Composite(VImage src, VImage dst, const int gravity) { + if(IsInputValidForComposition(src, dst)) { + // Enlarge overlay src, if required + if (src.width() < dst.width() || src.height() < dst.height()) { + // Calculate the (left, top) coordinates of the output image within the input image, applying the given gravity. + int left; + int top; + std::tie(left, top) = CalculateCrop(dst.width(), dst.height(), src.width(), src.height(), gravity); + // Embed onto transparent background + std::vector background { 0.0, 0.0, 0.0, 0.0 }; + src = src.embed(left, top, dst.width(), dst.height(), VImage::option() + ->set("extend", VIPS_EXTEND_BACKGROUND) + ->set("background", background) + ); + } + return CompositeImage(src, dst); + } + // If the input was not valid for composition the return the input image itself + return dst; + } + + + VImage Composite(VImage src, VImage dst, const int x, const int y) { + if(IsInputValidForComposition(src, dst)) { + // Enlarge overlay src, if required + if (src.width() < dst.width() || src.height() < dst.height()) { + // Calculate the (left, top) coordinates of the output image within the input image, applying the given gravity. + int left; + int top; + std::tie(left, top) = CalculateCrop(dst.width(), dst.height(), src.width(), src.height(), x, y); + // Embed onto transparent background + std::vector background { 0.0, 0.0, 0.0, 0.0 }; + src = src.embed(left, top, dst.width(), dst.height(), VImage::option() + ->set("extend", VIPS_EXTEND_BACKGROUND) + ->set("background", background) + ); + } + return CompositeImage(src, dst); + } + // If the input was not valid for composition the return the input image itself + return dst; + } + + bool IsInputValidForComposition(VImage src, VImage dst) { using sharp::CalculateCrop; using sharp::HasAlpha; @@ -29,20 +72,10 @@ namespace sharp { throw VError("Overlay image must have same dimensions or smaller"); } - // Enlarge overlay src, if required - if (src.width() < dst.width() || src.height() < dst.height()) { - // Calculate the (left, top) coordinates of the output image within the input image, applying the given gravity. - int left; - int top; - std::tie(left, top) = CalculateCrop(dst.width(), dst.height(), src.width(), src.height(), gravity); - // Embed onto transparent background - std::vector background { 0.0, 0.0, 0.0, 0.0 }; - src = src.embed(left, top, dst.width(), dst.height(), VImage::option() - ->set("extend", VIPS_EXTEND_BACKGROUND) - ->set("background", background) - ); - } + return true; + } + VImage CompositeImage(VImage src, VImage dst) { // Split src into non-alpha and alpha channels VImage srcWithoutAlpha = src.extract_band(0, VImage::option()->set("n", src.bands() - 1)); VImage srcAlpha = src[src.bands() - 1] * (1.0 / 255.0); diff --git a/src/operations.h b/src/operations.h index 43fd9213..dee7dbe4 100644 --- a/src/operations.h +++ b/src/operations.h @@ -16,8 +16,24 @@ namespace sharp { VImage Composite(VImage src, VImage dst, const int gravity); /* - Cutout src over dst with given gravity. + Alpha composite src over dst with given x and y offsets. + Assumes alpha channels are already premultiplied and will be unpremultiplied after. */ + VImage Composite(VImage src, VImage dst, const int x, const int y); + + /* + Check if the src and dst Images for composition operation are valid + */ + bool IsInputValidForComposition(VImage src, VImage dst); + + /* + Given a valid src and dst, returns the composite of the two images + */ + VImage CompositeImage(VImage src, VImage dst); + + /* + Cutout src over dst with given gravity. + */ VImage Cutout(VImage src, VImage dst, const int gravity); /* diff --git a/src/pipeline.cc b/src/pipeline.cc index 85278037..d0b1b77c 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -703,10 +703,19 @@ class PipelineWorker : public AsyncWorker { int left; int top; overlayImage = overlayImage.replicate(across, down); - // the overlayGravity will now be used to CalculateCrop for extract_area - std::tie(left, top) = CalculateCrop( - overlayImage.width(), overlayImage.height(), image.width(), image.height(), baton->overlayGravity - ); + + if(baton->overlayXOffset >= 0 && baton->overlayYOffset >= 0) { + // the overlayX/YOffsets will now be used to CalculateCrop for extract_area + std::tie(left, top) = CalculateCrop( + overlayImage.width(), overlayImage.height(), image.width(), image.height(), + baton->overlayXOffset, baton->overlayYOffset + ); + } else { + // the overlayGravity will now be used to CalculateCrop for extract_area + std::tie(left, top) = CalculateCrop( + overlayImage.width(), overlayImage.height(), image.width(), image.height(), baton->overlayGravity + ); + } overlayImage = overlayImage.extract_area( left, top, image.width(), image.height() ); @@ -720,8 +729,13 @@ class PipelineWorker : public AsyncWorker { } else { // Ensure overlay is premultiplied sRGB overlayImage = overlayImage.colourspace(VIPS_INTERPRETATION_sRGB).premultiply(); - // Composite images with given gravity - image = Composite(overlayImage, image, baton->overlayGravity); + if(baton->overlayXOffset >= 0 && baton->overlayYOffset >= 0) { + // Composite images with given offsets + image = Composite(overlayImage, image, baton->overlayXOffset, baton->overlayYOffset); + } else { + // Composite images with given gravity + image = Composite(overlayImage, image, baton->overlayGravity); + } } } @@ -1105,6 +1119,8 @@ NAN_METHOD(pipeline) { baton->overlayBufferIn = node::Buffer::Data(overlayBufferIn); } baton->overlayGravity = attrAs(options, "overlayGravity"); + baton->overlayXOffset = attrAs(options, "overlayXOffset"); + baton->overlayYOffset = attrAs(options, "overlayYOffset"); baton->overlayTile = attrAs(options, "overlayTile"); baton->overlayCutout = attrAs(options, "overlayCutout"); // Resize options diff --git a/src/pipeline.h b/src/pipeline.h index 2e50be9c..96a1705c 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -35,6 +35,8 @@ struct PipelineBaton { char *overlayBufferIn; size_t overlayBufferInLength; int overlayGravity; + int overlayXOffset; + int overlayYOffset; bool overlayTile; bool overlayCutout; int topOffsetPre; @@ -107,6 +109,8 @@ struct PipelineBaton { bufferOutLength(0), overlayBufferInLength(0), overlayGravity(0), + overlayXOffset(-1), + overlayYOffset(-1), overlayTile(false), overlayCutout(false), topOffsetPre(-1), diff --git a/test/fixtures/expected/overlay-offset-0.jpg b/test/fixtures/expected/overlay-offset-0.jpg new file mode 100644 index 00000000..e64b8e50 Binary files /dev/null and b/test/fixtures/expected/overlay-offset-0.jpg differ diff --git a/test/fixtures/expected/overlay-offset-with-gravity-tile.jpg b/test/fixtures/expected/overlay-offset-with-gravity-tile.jpg new file mode 100644 index 00000000..fc059683 Binary files /dev/null and b/test/fixtures/expected/overlay-offset-with-gravity-tile.jpg differ diff --git a/test/fixtures/expected/overlay-offset-with-gravity.jpg b/test/fixtures/expected/overlay-offset-with-gravity.jpg new file mode 100644 index 00000000..e217a345 Binary files /dev/null and b/test/fixtures/expected/overlay-offset-with-gravity.jpg differ diff --git a/test/fixtures/expected/overlay-offset-with-tile.jpg b/test/fixtures/expected/overlay-offset-with-tile.jpg new file mode 100644 index 00000000..fc059683 Binary files /dev/null and b/test/fixtures/expected/overlay-offset-with-tile.jpg differ diff --git a/test/fixtures/expected/overlay-valid-offsets-10-10.jpg b/test/fixtures/expected/overlay-valid-offsets-10-10.jpg new file mode 100644 index 00000000..e217a345 Binary files /dev/null and b/test/fixtures/expected/overlay-valid-offsets-10-10.jpg differ diff --git a/test/fixtures/expected/overlay-valid-offsets-100-300.jpg b/test/fixtures/expected/overlay-valid-offsets-100-300.jpg new file mode 100644 index 00000000..1d38f437 Binary files /dev/null and b/test/fixtures/expected/overlay-valid-offsets-100-300.jpg differ diff --git a/test/fixtures/expected/overlay-very-large-offset.jpg b/test/fixtures/expected/overlay-very-large-offset.jpg new file mode 100644 index 00000000..c899fc5d Binary files /dev/null and b/test/fixtures/expected/overlay-very-large-offset.jpg differ diff --git a/test/unit/overlay.js b/test/unit/overlay.js index f1e31cc1..553fb27b 100644 --- a/test/unit/overlay.js +++ b/test/unit/overlay.js @@ -264,6 +264,164 @@ describe('Overlays', function() { }); }); + describe("Overlay with top-left offsets", function() { + it('Overlay with 10px top & 10px left offsets', function(done) { + var expected = fixtures.expected('overlay-valid-offsets-10-10.jpg'); + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + top: 10, + left: 10 + }) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(expected, data, done); + }); + + }); + + it('Overlay with 100px top & 300px left offsets', function(done) { + var expected = fixtures.expected('overlay-valid-offsets-100-300.jpg'); + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + top: 100, + left: 300 + }) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(expected, data, done); + }); + + }); + + it('Overlay with only top offset', function() { + assert.throws(function() { + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + top: 1000 + }); + }); + }); + + it('Overlay with only left offset', function() { + assert.throws(function() { + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + left: 1000 + }); + }); + }); + + it('Overlay with negative offsets', function() { + assert.throws(function() { + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + top: -1000, + left: -1000 + }); + }); + }); + + it('Overlay with 0 offset', function(done) { + var expected = fixtures.expected('overlay-offset-0.jpg'); + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + top: 0, + left: 0 + }) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(expected, data, done); + }); + + }); + + it('Overlay with offset and gravity', function(done) { + var expected = fixtures.expected('overlay-offset-with-gravity.jpg'); + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + left: 10, + top: 10, + gravity : 4 + + }) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(expected, data, done); + }); + + }); + + it('Overlay with offset and gravity and tile', function(done) { + var expected = fixtures.expected('overlay-offset-with-gravity-tile.jpg'); + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + left: 10, + top: 10, + gravity : 4, + tile: true + }) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(expected, data, done); + }); + + }); + + it('Overlay with offset and tile', function(done) { + var expected = fixtures.expected('overlay-offset-with-tile.jpg'); + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + left: 10, + top: 10, + tile: true + }) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(expected, data, done); + }); + + }); + + + it('Overlay with very large offset', function(done) { + var expected = fixtures.expected('overlay-very-large-offset.jpg'); + sharp(fixtures.inputJpg) + .resize(400) + .overlayWith(fixtures.inputPngWithTransparency16bit, { + left: 1000000, + top: 100000 + }) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(expected, data, done); + }); + + }); + }); + it('With tile enabled and image rotated 90 degrees', function(done) { var expected = fixtures.expected('overlay-tile-rotated90.jpg'); sharp(fixtures.inputJpg)