diff --git a/.jshintrc b/.jshintrc index 4e909da6..c751c36a 100644 --- a/.jshintrc +++ b/.jshintrc @@ -4,6 +4,7 @@ "maxparams": 4, "maxcomplexity": 13, "globals": { + "before": true, "describe": true, "it": true } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dd2ee98..ddb6eb07 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,8 @@ Please select the `master` branch as the destination for your Pull Request so yo Please squash your changes into a single commit using a command like `git rebase -i upstream/master`. +To test C++ changes, you can compile the module using `npm install` and then run the tests using `npm test`. + ## Submit a Pull Request with a new feature Please add JavaScript [unit tests](https://github.com/lovell/sharp/tree/master/test/unit) to cover your new feature. A test coverage report for the JavaScript code is generated in the `coverage/lcov-report` directory. diff --git a/binding.gyp b/binding.gyp index 892153aa..bdb47907 100755 --- a/binding.gyp +++ b/binding.gyp @@ -3,6 +3,7 @@ 'target_name': 'sharp', 'sources': [ 'src/common.cc', + 'src/composite.c', 'src/utilities.cc', 'src/metadata.cc', 'src/resize.cc', diff --git a/index.js b/index.js index a1083334..dad2db7d 100755 --- a/index.js +++ b/index.js @@ -60,6 +60,8 @@ var Sharp = function(input) { gamma: 0, greyscale: false, normalize: 0, + // overlay + overlayPath: '', // output options output: '__input', progressive: false, @@ -203,6 +205,17 @@ Sharp.prototype.flatten = function(flatten) { return this; }; +Sharp.prototype.overlayWith = function(overlayPath) { + if (typeof overlayPath !== 'string') { + throw new Error('The overlay path must be a string'); + } + if (overlayPath === '') { + throw new Error('The overlay path cannot be empty'); + } + this.options.overlayPath = overlayPath; + return this; +}; + /* 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/composite.c b/src/composite.c new file mode 100644 index 00000000..d8c922c2 --- /dev/null +++ b/src/composite.c @@ -0,0 +1,125 @@ +#include +#include + + +const int ALPHA_BAND_INDEX = 3; +const int NUM_COLOR_BANDS = 3; + + +/* + Composite images `src` and `dst` + */ +int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **out) { + if (src->Bands != 4 || dst->Bands != 4) + return -1; + + // Extract RGB bands: + VipsImage *srcRGB; + VipsImage *dstRGB; + if (vips_extract_band(src, &srcRGB, 0, "n", NUM_COLOR_BANDS, NULL) || + vips_extract_band(dst, &dstRGB, 0, "n", NUM_COLOR_BANDS, NULL)) + return -1; + + vips_object_local(context, srcRGB); + vips_object_local(context, dstRGB); + + // Extract alpha bands: + VipsImage *srcAlpha; + VipsImage *dstAlpha; + if (vips_extract_band(src, &srcAlpha, ALPHA_BAND_INDEX, NULL) || + vips_extract_band(dst, &dstAlpha, ALPHA_BAND_INDEX, NULL)) + return -1; + + vips_object_local(context, srcAlpha); + vips_object_local(context, dstAlpha); + + // Compute normalized input alpha channels: + VipsImage *srcAlphaNormalized; + VipsImage *dstAlphaNormalized; + if (vips_linear1(srcAlpha, &srcAlphaNormalized, 1.0 / 255.0, 0.0, NULL) || + vips_linear1(dstAlpha, &dstAlphaNormalized, 1.0 / 255.0, 0.0, NULL)) + return -1; + + vips_object_local(context, srcAlphaNormalized); + vips_object_local(context, dstAlphaNormalized); + + // + // Compute normalized output alpha channel: + // + // References: + // - http://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending + // - https://github.com/jcupitt/ruby-vips/issues/28#issuecomment-9014826 + // + // out_a = src_a + dst_a * (1 - src_a) + // ^^^^^^^^^^^ + // t0 + // ^^^^^^^^^^^^^^^^^^^ + // t1 + VipsImage *t0; + VipsImage *t1; + VipsImage *outAlphaNormalized; + if (vips_linear1(srcAlphaNormalized, &t0, -1.0, 1.0, NULL) || + vips_multiply(dstAlphaNormalized, t0, &t1, NULL) || + vips_add(srcAlphaNormalized, t1, &outAlphaNormalized, NULL)) + return -1; + + vips_object_local(context, t0); + vips_object_local(context, t1); + vips_object_local(context, outAlphaNormalized); + + // + // Compute output RGB channels: + // + // Wikipedia: + // out_rgb = (src_rgb * src_a + dst_rgb * dst_a * (1 - src_a)) / out_a + // + // `vips_ifthenelse` with `blend=TRUE`: http://bit.ly/1KoSsga + // out = (cond / 255) * in1 + (1 - cond / 255) * in2 + // + // Substitutions: + // + // cond --> src_a + // in1 --> src_rgb + // in2 --> dst_rgb * dst_a (premultiplied destination RGB) + // + // Finally, manually divide by `out_a` to unpremultiply the RGB channels. + // Failing to do so results in darker than expected output with low + // opacity images. + // + VipsImage *dstRGBPremultiplied; + if (vips_multiply(dstRGB, dstAlphaNormalized, &dstRGBPremultiplied, NULL)) + return -1; + + vips_object_local(context, dstRGBPremultiplied); + + VipsImage *outRGBPremultiplied; + if (vips_ifthenelse(srcAlpha, srcRGB, dstRGBPremultiplied, + &outRGBPremultiplied, "blend", TRUE, NULL)) + return -1; + + vips_object_local(context, outRGBPremultiplied); + + // Unpremultiply RGB channels: + VipsImage *outRGB; + if (vips_divide(outRGBPremultiplied, outAlphaNormalized, &outRGB, NULL)) + return -1; + + vips_object_local(context, outRGB); + + // Denormalize output alpha channel: + VipsImage *outAlpha; + if (vips_linear1(outAlphaNormalized, &outAlpha, 255.0, 0.0, NULL)) + return -1; + + vips_object_local(context, outAlpha); + + // Combine RGB and alpha channel into output image: + VipsImage *joined; + if (vips_bandjoin2(outRGB, outAlpha, &joined, NULL)) + return -1; + + // Return a reference to the output image: + *out = joined; + + return 0; +} diff --git a/src/composite.h b/src/composite.h new file mode 100644 index 00000000..8df4acb6 --- /dev/null +++ b/src/composite.h @@ -0,0 +1,17 @@ +#ifndef SRC_COMPOSITE_H_ +#define SRC_COMPOSITE_H_ + + +#ifdef __cplusplus +extern "C" { +#endif +/* + Composite images `src` and `dst`. +*/ +int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **out); + +#ifdef __cplusplus +} +#endif + +#endif // SRC_COMPOSITE_H_ diff --git a/src/resize.cc b/src/resize.cc index e342a147..9cc2b4ad 100755 --- a/src/resize.cc +++ b/src/resize.cc @@ -8,6 +8,7 @@ #include "nan.h" #include "common.h" +#include "composite.h" #include "resize.h" using v8::Handle; @@ -81,6 +82,7 @@ struct ResizeBaton { int sharpenRadius; double sharpenFlat; double sharpenJagged; + std::string overlayPath; double gamma; bool greyscale; bool normalize; @@ -790,6 +792,54 @@ class ResizeWorker : public NanAsyncWorker { } #endif + // Composite with overlay, if present + if (!baton->overlayPath.empty()) { + VipsImage *overlayImage = NULL; + ImageType overlayImageType = ImageType::UNKNOWN; + overlayImageType = DetermineImageType(baton->overlayPath.c_str()); + if (overlayImageType != ImageType::UNKNOWN) { + overlayImage = InitImage(baton->overlayPath.c_str(), baton->accessMethod); + if (overlayImage == NULL) { + (baton->err).append("Overlay input file has corrupt header"); + overlayImageType = ImageType::UNKNOWN; + } + } else { + (baton->err).append("Overlay input file is of an unsupported image format"); + } + + if (overlayImage == NULL || overlayImageType == ImageType::UNKNOWN) { + return Error(); + } + + if (!HasAlpha(overlayImage)) { + (baton->err).append("Overlay input must have an alpha channel"); + return Error(); + } + + if (!HasAlpha(image)) { + (baton->err).append("Input image must have an alpha channel"); + return Error(); + } + + if (overlayImage->Bands != 4) { + (baton->err).append("Overlay input image must have 4 channels"); + return Error(); + } + + if (image->Bands != 4) { + (baton->err).append("Input image must have 4 channels"); + return Error(); + } + + VipsImage *composited; + if (Composite(hook, overlayImage, image, &composited)) { + (baton->err).append("Failed to composite images"); + return Error(); + } + vips_object_local(hook, composited); + image = composited; + } + // Convert image to sRGB, if not already if (image->Type != VIPS_INTERPRETATION_sRGB) { // Switch intrepretation to sRGB @@ -1175,6 +1225,8 @@ NAN_METHOD(resize) { for (int i = 0; i < 4; i++) { baton->background[i] = background->Get(i)->NumberValue(); } + // Overlay options + baton->overlayPath = *String::Utf8Value(options->Get(NanNew("overlayPath"))->ToString()); // Resize options baton->withoutEnlargement = options->Get(NanNew("withoutEnlargement"))->BooleanValue(); baton->gravity = options->Get(NanNew("gravity"))->Int32Value(); diff --git a/test/fixtures/alpha-layer-0-background.png b/test/fixtures/alpha-layer-0-background.png new file mode 100644 index 00000000..68ae11b2 Binary files /dev/null and b/test/fixtures/alpha-layer-0-background.png differ diff --git a/test/fixtures/alpha-layer-1-fill-low-alpha.png b/test/fixtures/alpha-layer-1-fill-low-alpha.png new file mode 100644 index 00000000..db8fc5f5 Binary files /dev/null and b/test/fixtures/alpha-layer-1-fill-low-alpha.png differ diff --git a/test/fixtures/alpha-layer-1-fill.png b/test/fixtures/alpha-layer-1-fill.png new file mode 100644 index 00000000..9df26d6c Binary files /dev/null and b/test/fixtures/alpha-layer-1-fill.png differ diff --git a/test/fixtures/alpha-layer-2-ink-low-alpha.png b/test/fixtures/alpha-layer-2-ink-low-alpha.png new file mode 100644 index 00000000..31bf629d Binary files /dev/null and b/test/fixtures/alpha-layer-2-ink-low-alpha.png differ diff --git a/test/fixtures/alpha-layer-2-ink.png b/test/fixtures/alpha-layer-2-ink.png new file mode 100644 index 00000000..6bfb355f Binary files /dev/null and b/test/fixtures/alpha-layer-2-ink.png differ diff --git a/test/fixtures/expected/alpha-layer-01.png b/test/fixtures/expected/alpha-layer-01.png new file mode 100644 index 00000000..8ad37887 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-01.png differ diff --git a/test/fixtures/expected/alpha-layer-012-low-alpha.png b/test/fixtures/expected/alpha-layer-012-low-alpha.png new file mode 100644 index 00000000..697c2a00 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-012-low-alpha.png differ diff --git a/test/fixtures/expected/alpha-layer-012.png b/test/fixtures/expected/alpha-layer-012.png new file mode 100644 index 00000000..2732965f Binary files /dev/null and b/test/fixtures/expected/alpha-layer-012.png differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index 29d704fb..e04941dd 100755 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -11,7 +11,7 @@ var getPath = function(filename) { // Generates a 64-bit-as-binary-string image fingerprint // Based on the dHash gradient method - see http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html -var fingerprint = function(image, done) { +var fingerprint = function(image, callback) { sharp(image) .greyscale() .normalise() @@ -21,7 +21,7 @@ var fingerprint = function(image, done) { .raw() .toBuffer(function(err, data) { if (err) { - done(err); + callback(err); } else { var fingerprint = ''; for (var col = 0; col < 8; col++) { @@ -32,7 +32,7 @@ var fingerprint = function(image, done) { fingerprint = fingerprint + (left < right ? '1' : '0'); } } - done(null, fingerprint); + callback(null, fingerprint); } }); }; @@ -52,6 +52,11 @@ module.exports = { inputPngWithTransparency: getPath('blackbug.png'), // public domain inputPngWithGreyAlpha: getPath('grey-8bit-alpha.png'), inputPngWithOneColor: getPath('2x2_fdcce6.png'), + inputPngOverlayLayer0: getPath('alpha-layer-0-background.png'), + inputPngOverlayLayer1: getPath('alpha-layer-1-fill.png'), + inputPngOverlayLayer2: getPath('alpha-layer-2-ink.png'), + inputPngOverlayLayer1LowAlpha: getPath('alpha-layer-1-fill-low-alpha.png'), + inputPngOverlayLayer2LowAlpha: getPath('alpha-layer-2-ink-low-alpha.png'), inputWebP: getPath('4.webp'), // http://www.gstatic.com/webp/gallery/4.webp inputTiff: getPath('G31D.TIF'), // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm @@ -75,19 +80,46 @@ module.exports = { }, // Verify similarity of expected vs actual images via fingerprint - assertSimilar: function(expectedImage, actualImage, done) { + // Specify distance threshold using `options={threshold: 42}`, default + // `threshold` is 5; + assertSimilar: function(expectedImage, actualImage, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + if (typeof options === 'undefined' && options === null) { + options = {}; + } + + if (options.threshold === null || typeof options.threshold === 'undefined') { + options.threshold = 5; // ~7% threshold + } + + if (typeof options.threshold !== 'number') { + throw new TypeError('`options.threshold` must be a number'); + } + + if (typeof callback !== 'function') { + throw new TypeError('`callback` must be a function'); + } + fingerprint(expectedImage, function(err, expectedFingerprint) { - if (err) throw err; + if (err) return callback(err); fingerprint(actualImage, function(err, actualFingerprint) { - if (err) throw err; + if (err) return callback(err); var distance = 0; for (var i = 0; i < 64; i++) { if (expectedFingerprint[i] !== actualFingerprint[i]) { distance++; } } - assert.strictEqual(true, distance <= 5); // ~7% threshold - done(); + + if (distance > options.threshold) { + return callback(new Error('Maximum similarity distance: ' + options.threshold + '. Actual: ' + distance)); + } + + callback(); }); }); } diff --git a/test/unit/overlay.js b/test/unit/overlay.js new file mode 100644 index 00000000..3b716549 --- /dev/null +++ b/test/unit/overlay.js @@ -0,0 +1,61 @@ +'use strict'; + +var fixtures = require('../fixtures'); +var fs = require('fs'); +var sharp = require('../../index'); + +sharp.cache(0); + +// Main +describe('Overlays', function() { + it('Overlay transparent PNG on solid background', function(done) { + sharp(fixtures.inputPngOverlayLayer0) + .overlayWith(fixtures.inputPngOverlayLayer1) + .toBuffer(function (error, data, info) { + if (error) return done(error); + + fixtures.assertSimilar(fixtures.expected('alpha-layer-01.png'), data, {threshold: 0}, done); + }); + }); + + it('Composite three transparent PNGs into one', function(done) { + sharp(fixtures.inputPngOverlayLayer0) + .overlayWith(fixtures.inputPngOverlayLayer1) + .toBuffer(function (error, data, info) { + if (error) return done(error); + + sharp(data) + .overlayWith(fixtures.inputPngOverlayLayer2) + .toBuffer(function (error, data, info) { + if (error) return done(error); + + fixtures.assertSimilar(fixtures.expected('alpha-layer-012.png'), data, {threshold: 0}, done); + }); + }); + }); + + // This tests that alpha channel unpremultiplication is correct: + it('Composite three low-alpha transparent PNGs into one', function(done) { + sharp(fixtures.inputPngOverlayLayer1LowAlpha) + .overlayWith(fixtures.inputPngOverlayLayer2LowAlpha) + .toBuffer(function (error, data, info) { + if (error) return done(error); + + fixtures.assertSimilar(fixtures.expected('alpha-layer-012-low-alpha.png'), data, {threshold: 0}, done); + }); + }); + + // This tests that alpha channel unpremultiplication is correct: + it('Composite transparent PNG onto JPEG', function(done) { + sharp(fixtures.inputJpg) + .overlayWith(fixtures.inputPngOverlayLayer1) + .toBuffer(function (error, data, info) { + if (error.message !== 'Input image must have an alpha channel') { + return done(new Error('Unexpected error: ' + error.message)); + } + + done(); + }); + }); + +});