Add top/left offset support to overlayWith operation (#473)
70
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
|
||||
|
@ -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<int, int> 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.
|
||||
|
@ -108,8 +108,16 @@ namespace sharp {
|
||||
std::tuple<int, int> 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<int, int> 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_
|
||||
|
@ -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<double> 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<double> 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<double> 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);
|
||||
|
@ -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);
|
||||
|
||||
/*
|
||||
|
@ -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<int32_t>(options, "overlayGravity");
|
||||
baton->overlayXOffset = attrAs<int32_t>(options, "overlayXOffset");
|
||||
baton->overlayYOffset = attrAs<int32_t>(options, "overlayYOffset");
|
||||
baton->overlayTile = attrAs<bool>(options, "overlayTile");
|
||||
baton->overlayCutout = attrAs<bool>(options, "overlayCutout");
|
||||
// Resize options
|
||||
|
@ -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),
|
||||
|
BIN
test/fixtures/expected/overlay-offset-0.jpg
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
test/fixtures/expected/overlay-offset-with-gravity-tile.jpg
vendored
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
test/fixtures/expected/overlay-offset-with-gravity.jpg
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
test/fixtures/expected/overlay-offset-with-tile.jpg
vendored
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
test/fixtures/expected/overlay-valid-offsets-10-10.jpg
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
test/fixtures/expected/overlay-valid-offsets-100-300.jpg
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
test/fixtures/expected/overlay-very-large-offset.jpg
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
@ -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)
|
||||
|