diff --git a/README.md b/README.md index eb75fa9d..13d3c25c 100755 --- a/README.md +++ b/README.md @@ -321,7 +321,15 @@ Rotate the output image by either an explicit angle or auto-orient based on the `angle`, if present, is a Number with a value of `0`, `90`, `180` or `270`. -Use this method without `angle` to determine the angle from EXIF data. Mirroring is currently unsupported. +Use this method without `angle` to determine the angle from EXIF data. Mirroring is supported and may infer the use of a `flip` operation. + +#### flip() + +Flip the image about the vertical Y axis. This always occurs after rotation, if any. + +#### flop() + +Flop the image about the horizontal X axis. This always occurs after rotation, if any. #### withoutEnlargement() diff --git a/index.js b/index.js index 16ad407d..123488d8 100755 --- a/index.js +++ b/index.js @@ -31,6 +31,8 @@ var Sharp = function(input) { canvas: 'c', gravity: 0, angle: 0, + flip: false, + flop: false, withoutEnlargement: false, interpolator: 'bilinear', // operations @@ -168,6 +170,22 @@ Sharp.prototype.rotate = function(angle) { return this; }; +/* + Flip the image vertically, about the Y axis +*/ +Sharp.prototype.flip = function(flip) { + this.options.flip = (typeof flip === 'boolean') ? flip : true; + return this; +}; + +/* + Flop the image horizontally, about the X axis +*/ +Sharp.prototype.flop = function(flop) { + this.options.flop = (typeof flop === 'boolean') ? flop : true; + return this; +}; + /* Do not enlarge the output if the input width *or* height are already less than the required dimensions This is equivalent to GraphicsMagick's ">" geometry option: diff --git a/src/sharp.cc b/src/sharp.cc index 3589d14c..79836bb8 100755 --- a/src/sharp.cc +++ b/src/sharp.cc @@ -43,12 +43,14 @@ struct resize_baton { bool sharpen; double gamma; bool greyscale; + int angle; + bool flip; + bool flop; bool progressive; bool without_enlargement; VipsAccess access_method; int quality; int compression_level; - int angle; std::string err; bool with_metadata; @@ -65,6 +67,8 @@ struct resize_baton { sharpen(false), gamma(0.0), greyscale(false), + flip(false), + flop(false), progressive(false), without_enlargement(false), with_metadata(false) {} @@ -125,15 +129,16 @@ typedef enum { } Angle; /* - Calculate the angle of rotation for the output image. + Calculate the angle of rotation and need-to-flip for the output image. In order of priority: 1. Use explicitly requested angle (supports 90, 180, 270) - 2. Use input image EXIF Orientation header (does not support mirroring) + 2. Use input image EXIF Orientation header - supports mirroring 3. Otherwise default to zero, i.e. no rotation */ -static Angle -sharp_calc_rotation(int const angle, VipsImage const *input) { +static std::tuple +sharp_calc_rotation_and_flip(int const angle, VipsImage const *input) { Angle rotate = ANGLE_0; + bool flip = FALSE; if (angle == -1) { const char *exif; if (!vips_image_get_string(input, "exif-ifd0-Orientation", &exif)) { @@ -143,6 +148,17 @@ sharp_calc_rotation(int const angle, VipsImage const *input) { rotate = ANGLE_180; } else if (exif[0] == 0x38) { // "8" rotate = ANGLE_270; + } else if (exif[0] == 0x32) { // "2" (flip 1) + flip = TRUE; + } else if (exif[0] == 0x37) { // "7" (flip 6) + rotate = ANGLE_90; + flip = TRUE; + } else if (exif[0] == 0x34) { // "4" (flip 3) + rotate = ANGLE_180; + flip = TRUE; + } else if (exif[0] == 0x35) { // "5" (flip 8) + rotate = ANGLE_270; + flip = TRUE; } } } else { @@ -154,7 +170,7 @@ sharp_calc_rotation(int const angle, VipsImage const *input) { rotate = ANGLE_270; } } - return rotate; + return std::make_tuple(rotate, flip); } /* @@ -443,13 +459,19 @@ class ResizeWorker : public NanAsyncWorker { int inputHeight = image->Ysize; // Calculate angle of rotation, to be carried out later - Angle rotation = sharp_calc_rotation(baton->angle, image); + Angle rotation; + bool flip; + std::tie(rotation, flip) = sharp_calc_rotation_and_flip(baton->angle, image); if (rotation == ANGLE_90 || rotation == ANGLE_270) { // Swap input output width and height when rotating by 90 or 270 degrees int swap = inputWidth; inputWidth = inputHeight; inputHeight = swap; } + if (flip && !baton->flip) { + // Add flip operation due to EXIF mirroring + baton->flip = TRUE; + } // Scaling calculations double factor; @@ -645,6 +667,28 @@ class ResizeWorker : public NanAsyncWorker { image = rotated; } + // Flip (mirror about Y axis) + if (baton->flip) { + VipsImage *flipped = vips_image_new(); + vips_object_local(hook, flipped); + if (vips_flip(image, &flipped, VIPS_DIRECTION_VERTICAL, NULL)) { + return resize_error(baton, hook); + } + g_object_unref(image); + image = flipped; + } + + // Flop (mirror about X axis) + if (baton->flop) { + VipsImage *flopped = vips_image_new(); + vips_object_local(hook, flopped); + if (vips_flip(image, &flopped, VIPS_DIRECTION_HORIZONTAL, NULL)) { + return resize_error(baton, hook); + } + g_object_unref(image); + image = flopped; + } + // Crop/embed if (image->Xsize != baton->width || image->Ysize != baton->height) { if (baton->canvas == EMBED) { @@ -960,6 +1004,8 @@ NAN_METHOD(resize) { baton->gamma = options->Get(NanNew("gamma"))->NumberValue(); baton->greyscale = options->Get(NanNew("greyscale"))->BooleanValue(); baton->angle = options->Get(NanNew("angle"))->Int32Value(); + baton->flip = options->Get(NanNew("flip"))->BooleanValue(); + baton->flop = options->Get(NanNew("flop"))->BooleanValue(); // Output options baton->progressive = options->Get(NanNew("progressive"))->BooleanValue(); baton->quality = options->Get(NanNew("quality"))->Int32Value(); diff --git a/test/fixtures/Landscape_5.jpg b/test/fixtures/Landscape_5.jpg new file mode 100644 index 00000000..81a8af69 Binary files /dev/null and b/test/fixtures/Landscape_5.jpg differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index ab271bc0..5fb1d13a 100755 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -10,6 +10,7 @@ module.exports = { inputJpg: getPath('2569067123_aca715a2ee_o.jpg'), // http://www.flickr.com/photos/grizdave/2569067123/ inputJpgWithExif: getPath('Landscape_8.jpg'), // https://github.com/recurser/exif-orientation-examples/blob/master/Landscape_8.jpg + inputJpgWithExifMirroring: getPath('Landscape_5.jpg'), // https://github.com/recurser/exif-orientation-examples/blob/master/Landscape_5.jpg inputJpgWithGammaHoliness: getPath('gamma_dalai_lama_gray.jpg'), // http://www.4p8.com/eric.brasseur/gamma.html inputJpgWithCmykProfile: getPath('Channel_digital_image_CMYK_color.jpg'), // http://en.wikipedia.org/wiki/File:Channel_digital_image_CMYK_color.jpg diff --git a/test/unit/rotate.js b/test/unit/rotate.js index 65a72997..ae1af86f 100755 --- a/test/unit/rotate.js +++ b/test/unit/rotate.js @@ -19,25 +19,42 @@ describe('Rotation', function() { }); it('Input image has Orientation EXIF tag but do not rotate output', function(done) { - sharp(fixtures.inputJpgWithExif).resize(320).toBuffer(function(err, data, info) { - if (err) throw err; - assert.strictEqual(true, data.length > 0); - assert.strictEqual('jpeg', info.format); - assert.strictEqual(320, info.width); - assert.strictEqual(426, info.height); - done(); - }); + sharp(fixtures.inputJpgWithExif) + .resize(320) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual(true, data.length > 0); + assert.strictEqual('jpeg', info.format); + assert.strictEqual(320, info.width); + assert.strictEqual(426, info.height); + done(); + }); }); it('Input image has Orientation EXIF tag value of 8 (270 degrees), auto-rotate', function(done) { - sharp(fixtures.inputJpgWithExif).rotate().resize(320).toBuffer(function(err, data, info) { - if (err) throw err; - assert.strictEqual(true, data.length > 0); - assert.strictEqual('jpeg', info.format); - assert.strictEqual(320, info.width); - assert.strictEqual(240, info.height); - done(); - }); + sharp(fixtures.inputJpgWithExif) + .rotate() + .resize(320) + .toFile(fixtures.path('output.exif.8.jpg'), function(err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(320, info.width); + assert.strictEqual(240, info.height); + done(); + }); + }); + + it('Input image has Orientation EXIF tag value of 5 (270 degrees + flip), auto-rotate', function(done) { + sharp(fixtures.inputJpgWithExifMirroring) + .rotate() + .resize(320) + .toFile(fixtures.path('output.exif.5.jpg'), function(err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(320, info.width); + assert.strictEqual(240, info.height); + done(); + }); }); it('Attempt to auto-rotate using image that has no EXIF', function(done) { @@ -61,4 +78,57 @@ describe('Rotation', function() { done(); }); + it('Flip - vertical', function(done) { + sharp(fixtures.inputJpg) + .resize(320) + .flip() + .toFile(fixtures.path('output.flip.jpg'), function(err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(320, info.width); + assert.strictEqual(261, info.height); + done(); + }); + }); + + it('Flop - horizontal', function(done) { + sharp(fixtures.inputJpg) + .resize(320) + .flop() + .toFile(fixtures.path('output.flop.jpg'), function(err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(320, info.width); + assert.strictEqual(261, info.height); + done(); + }); + }); + + it('Flip and flop', function(done) { + sharp(fixtures.inputJpg) + .resize(320) + .flop() + .toFile(fixtures.path('output.flip.flop.jpg'), function(err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(320, info.width); + assert.strictEqual(261, info.height); + done(); + }); + }); + + it('Neither flip nor flop', function(done) { + sharp(fixtures.inputJpg) + .resize(320) + .flip(false) + .flop(false) + .toFile(fixtures.path('output.flip.flop.nope.jpg'), function(err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(320, info.width); + assert.strictEqual(261, info.height); + done(); + }); + }); + });