diff --git a/.travis.yml b/.travis.yml index 36bf998b..affefec8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ node_js: before_install: - sudo add-apt-repository ppa:lyrasis/precise-backports -y - sudo apt-get update -qq - - sudo apt-get install -qq automake gobject-introspection gtk-doc-tools libfftw3-dev libglib2.0-dev libjpeg-turbo8-dev libpng12-dev libwebp-dev libtiff4-dev libxml2-dev swig graphicsmagick libmagick++-dev + - sudo apt-get install -qq automake gobject-introspection gtk-doc-tools libfftw3-dev libglib2.0-dev libjpeg-turbo8-dev libpng12-dev libwebp-dev libtiff4-dev libexif-dev libxml2-dev swig graphicsmagick libmagick++-dev - git clone https://github.com/jcupitt/libvips.git - cd libvips - git checkout 7.38 diff --git a/README.md b/README.md index ee89b9d2..7e802033 100755 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The _gettext_ dependency of _libvips_ [can lead](https://github.com/lovell/sharp Compiling from source is recommended: - sudo apt-get install automake build-essential git gobject-introspection gtk-doc-tools libfftw3-dev libglib2.0-dev libjpeg-turbo8-dev libpng12-dev libwebp-dev libtiff5-dev libxml2-dev swig + sudo apt-get install automake build-essential git gobject-introspection gtk-doc-tools libfftw3-dev libglib2.0-dev libjpeg-turbo8-dev libpng12-dev libwebp-dev libtiff5-dev libexif-dev libxml2-dev swig git clone https://github.com/jcupitt/libvips.git cd libvips git checkout 7.38 @@ -85,20 +85,20 @@ sharp('input.jpg').resize(300, 200).write('output.jpg', function(err) { ``` ```javascript -sharp('input.jpg').resize(null, 200).progressive().toBuffer(function(err, outputBuffer) { +sharp('input.jpg').rotate().resize(null, 200).progressive().toBuffer(function(err, outputBuffer) { if (err) { throw err; } - // outputBuffer contains progressive JPEG image data, 200 pixels high + // outputBuffer contains 200px high progressive JPEG image data, auto-rotated using EXIF Orientation tag }); ``` ```javascript -sharp('input.png').resize(300).sharpen().quality(90).webp(function(err, outputBuffer) { +sharp('input.png').rotate(180).resize(300).sharpen().quality(90).webp(function(err, outputBuffer) { if (err) { throw err; } - // outputBuffer contains 300 pixels wide, sharpened, 90% quality WebP image data + // outputBuffer contains 300px wide, upside down, sharpened, 90% quality WebP image data }); ``` @@ -143,7 +143,7 @@ Constructor to which further methods are chained. `input` can be one of: ### resize(width, [height]) -Scale to `width` x `height`. By default, the resized image is cropped to the exact size specified. +Scale output to `width` x `height`. By default, the resized image is cropped to the exact size specified. `width` is the Number of pixels wide the resultant image should be. Use `null` or `undefined` to auto-scale the width to match the height. @@ -167,6 +167,14 @@ Embed the resized image on a white background of the exact size specified. Embed the resized image on a black background of the exact size specified. +### rotate([angle]) + +Rotate the output image by either an explicit angle or auto-orient based on the EXIF `Orientation` tag. Mirroring is not supported. + +`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. + ### sharpen() Perform a mild sharpen of the resultant image. This typically reduces performance by 30%. diff --git a/index.js b/index.js index 13fbe539..c5a670dd 100755 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ var Sharp = function(input) { width: -1, height: -1, canvas: 'c', + angle: 0, sharpen: false, progressive: false, sequentialRead: false, @@ -49,6 +50,20 @@ Sharp.prototype.max = function() { 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 +*/ +Sharp.prototype.rotate = function(angle) { + if (typeof angle === 'undefined') { + this.options.angle = -1; + } else if (!Number.isNaN(angle) && [0, 90, 180, 270].indexOf(angle) !== -1) { + this.options.angle = angle; + } else { + throw 'Unsupport angle (0, 90, 180, 270) ' + angle; + } + return this; +}; Sharp.prototype.sharpen = function(sharpen) { this.options.sharpen = (typeof sharpen === 'boolean') ? sharpen : true; @@ -147,6 +162,7 @@ Sharp.prototype._sharp = function(output, callback) { this.options.sequentialRead, this.options.quality, this.options.compressionLevel, + this.options.angle, callback ); return this; diff --git a/src/sharp.cc b/src/sharp.cc index e1feb4b5..e70a6133 100755 --- a/src/sharp.cc +++ b/src/sharp.cc @@ -27,6 +27,7 @@ struct resize_baton { VipsAccess access_method; int quality; int compressionLevel; + int angle; std::string err; resize_baton(): buffer_in_len(0), buffer_out_len(0), crop(false), max(false), sharpen(false), progressive(false) {} @@ -40,31 +41,31 @@ typedef enum { MAGICK } ImageType; -unsigned char MARKER_JPEG[] = {0xff, 0xd8}; -unsigned char MARKER_PNG[] = {0x89, 0x50}; -unsigned char MARKER_WEBP[] = {0x52, 0x49}; +unsigned char const MARKER_JPEG[] = {0xff, 0xd8}; +unsigned char const MARKER_PNG[] = {0x89, 0x50}; +unsigned char const MARKER_WEBP[] = {0x52, 0x49}; -bool ends_with(std::string const &str, std::string const &end) { +static bool ends_with(std::string const &str, std::string const &end) { return str.length() >= end.length() && 0 == str.compare(str.length() - end.length(), end.length(), end); } -bool is_jpeg(std::string const &str) { +static bool is_jpeg(std::string const &str) { return ends_with(str, ".jpg") || ends_with(str, ".jpeg") || ends_with(str, ".JPG") || ends_with(str, ".JPEG"); } -bool is_png(std::string const &str) { +static bool is_png(std::string const &str) { return ends_with(str, ".png") || ends_with(str, ".PNG"); } -bool is_webp(std::string const &str) { +static bool is_webp(std::string const &str) { return ends_with(str, ".webp") || ends_with(str, ".WEBP"); } -bool is_tiff(std::string const &str) { +static bool is_tiff(std::string const &str) { return ends_with(str, ".tif") || ends_with(str, ".tiff") || ends_with(str, ".TIF") || ends_with(str, ".TIFF"); } -void resize_error(resize_baton *baton, VipsImage *unref) { +static void resize_error(resize_baton *baton, VipsImage *unref) { (baton->err).append(vips_error_buffer()); vips_error_clear(); g_object_unref(unref); @@ -72,6 +73,38 @@ void resize_error(resize_baton *baton, VipsImage *unref) { return; } +/* + Calculate the angle of rotation 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) + 3. Otherwise default to zero, i.e. no rotation +*/ +static VipsAngle calc_rotation(int const angle, VipsImage const *input) { + VipsAngle rotate = VIPS_ANGLE_0; + if (angle == -1) { + const char *exif; + if (!vips_image_get_string(input, "exif-ifd0-Orientation", &exif)) { + if (exif[0] == 0x36) { // "6" + rotate = VIPS_ANGLE_90; + } else if (exif[0] == 0x33) { // "3" + rotate = VIPS_ANGLE_180; + } else if (exif[0] == 0x38) { // "8" + rotate = VIPS_ANGLE_270; + } + } + } else { + if (angle == 90) { + rotate = VIPS_ANGLE_90; + } else if (angle == 180) { + rotate = VIPS_ANGLE_180; + } else if (angle == 270) { + rotate = VIPS_ANGLE_270; + } + } + return rotate; +} + class ResizeWorker : public NanAsyncWorker { public: ResizeWorker(NanCallback *callback, resize_baton *baton) @@ -132,34 +165,47 @@ class ResizeWorker : public NanAsyncWorker { return; } + // Get input image width and height + int inputWidth = in->Xsize; + int inputHeight = in->Ysize; + + // Calculate angle of rotation, to be carried out later + VipsAngle rotation = calc_rotation(baton->angle, in); + if (rotation == VIPS_ANGLE_90 || rotation == VIPS_ANGLE_270) { + // Swap input output width and height when rotating by 90 or 270 degrees + int swap = inputWidth; + inputWidth = inputHeight; + inputHeight = swap; + } + // Scaling calculations double factor; if (baton->width > 0 && baton->height > 0) { // Fixed width and height - double xfactor = static_cast(in->Xsize) / static_cast(baton->width); - double yfactor = static_cast(in->Ysize) / static_cast(baton->height); + double xfactor = static_cast(inputWidth) / static_cast(baton->width); + double yfactor = static_cast(inputHeight) / static_cast(baton->height); factor = baton->crop ? std::min(xfactor, yfactor) : std::max(xfactor, yfactor); // if max is set, we need to compute the real size of the thumb image if (baton->max) { if (xfactor > yfactor) { - baton->height = round(static_cast(in->Ysize) / xfactor); + baton->height = round(static_cast(inputHeight) / xfactor); } else { - baton->width = round(static_cast(in->Xsize) / yfactor); + baton->width = round(static_cast(inputWidth) / yfactor); } } } else if (baton->width > 0) { // Fixed width, auto height - factor = static_cast(in->Xsize) / static_cast(baton->width); - baton->height = floor(static_cast(in->Ysize) / factor); + factor = static_cast(inputWidth) / static_cast(baton->width); + baton->height = floor(static_cast(inputHeight) / factor); } else if (baton->height > 0) { // Fixed height, auto width - factor = static_cast(in->Ysize) / static_cast(baton->height); - baton->width = floor(static_cast(in->Xsize) / factor); + factor = static_cast(inputHeight) / static_cast(baton->height); + baton->width = floor(static_cast(inputWidth) / factor); } else { // Identity transform factor = 1; - baton->width = in->Xsize; - baton->height = in->Ysize; + baton->width = inputWidth; + baton->height = inputHeight; } int shrink = floor(factor); if (shrink < 1) { @@ -186,7 +232,7 @@ class ResizeWorker : public NanAsyncWorker { // Recalculate integral shrink and double residual factor = std::max(factor, 1.0); shrink = floor(factor); - residual = shrink / factor; + residual = static_cast(shrink) / factor; // Reload input using shrink-on-load if (baton->buffer_in_len > 1) { if (vips_jpegload_buffer(baton->buffer_in, baton->buffer_in_len, &shrunk_on_load, "shrink", shrink_on_load, NULL)) { @@ -209,8 +255,16 @@ class ResizeWorker : public NanAsyncWorker { return resize_error(baton, shrunk_on_load); } // Recalculate residual float based on dimensions of required vs shrunk images - double residualx = static_cast(baton->width) / static_cast(shrunk->Xsize); - double residualy = static_cast(baton->height) / static_cast(shrunk->Ysize); + double shrunkWidth = shrunk->Xsize; + double shrunkHeight = shrunk->Ysize; + if (rotation == VIPS_ANGLE_90 || rotation == VIPS_ANGLE_270) { + // Swap input output width and height when rotating by 90 or 270 degrees + int swap = shrunkWidth; + shrunkWidth = shrunkHeight; + shrunkHeight = swap; + } + double residualx = static_cast(baton->width) / static_cast(shrunkWidth); + double residualy = static_cast(baton->height) / static_cast(shrunkHeight); if (baton->crop || baton->max) { residual = std::max(residualx, residualy); } else { @@ -232,30 +286,41 @@ class ResizeWorker : public NanAsyncWorker { } g_object_unref(shrunk); + // Rotate + VipsImage *rotated = vips_image_new(); + if (rotation != VIPS_ANGLE_0) { + if (vips_rot(affined, &rotated, rotation, NULL)) { + return resize_error(baton, affined); + } + } else { + vips_copy(affined, &rotated, NULL); + } + g_object_unref(affined); + // Crop/embed VipsImage *canvased = vips_image_new(); - if (affined->Xsize != baton->width || affined->Ysize != baton->height) { + if (rotated->Xsize != baton->width || rotated->Ysize != baton->height) { if (baton->crop || baton->max) { // Crop/max - int width = std::min(affined->Xsize, baton->width); - int height = std::min(affined->Ysize, baton->height); - int left = (affined->Xsize - width + 1) / 2; - int top = (affined->Ysize - height + 1) / 2; - if (vips_extract_area(affined, &canvased, left, top, width, height, NULL)) { - return resize_error(baton, affined); + int width = std::min(rotated->Xsize, baton->width); + int height = std::min(rotated->Ysize, baton->height); + int left = (rotated->Xsize - width + 1) / 2; + int top = (rotated->Ysize - height + 1) / 2; + if (vips_extract_area(rotated, &canvased, left, top, width, height, NULL)) { + return resize_error(baton, rotated); } } else { // Embed - int left = (baton->width - affined->Xsize) / 2; - int top = (baton->height - affined->Ysize) / 2; - if (vips_embed(affined, &canvased, left, top, baton->width, baton->height, "extend", baton->extend, NULL)) { - return resize_error(baton, affined); + int left = (baton->width - rotated->Xsize) / 2; + int top = (baton->height - rotated->Ysize) / 2; + if (vips_embed(rotated, &canvased, left, top, baton->width, baton->height, "extend", baton->extend, NULL)) { + return resize_error(baton, rotated); } } } else { - vips_copy(affined, &canvased, NULL); + vips_copy(rotated, &canvased, NULL); } - g_object_unref(affined); + g_object_unref(rotated); // Mild sharpen VipsImage *sharpened = vips_image_new(); @@ -366,8 +431,9 @@ NAN_METHOD(resize) { baton->access_method = args[8]->BooleanValue() ? VIPS_ACCESS_SEQUENTIAL : VIPS_ACCESS_RANDOM; baton->quality = args[9]->Int32Value(); baton->compressionLevel = args[10]->Int32Value(); + baton->angle = args[11]->Int32Value(); - NanCallback *callback = new NanCallback(args[11].As()); + NanCallback *callback = new NanCallback(args[12].As()); NanAsyncQueueWorker(new ResizeWorker(callback, baton)); NanReturnUndefined(); diff --git a/tests/fixtures/Landscape_8.jpg b/tests/fixtures/Landscape_8.jpg new file mode 100644 index 00000000..3f51d287 Binary files /dev/null and b/tests/fixtures/Landscape_8.jpg differ diff --git a/tests/perf.js b/tests/perf.js index bf0fdb4b..c8ac6d54 100755 --- a/tests/perf.js +++ b/tests/perf.js @@ -178,6 +178,18 @@ async.series({ } }); } + }).add("sharp-file-buffer-rotate", { + defer: true, + fn: function(deferred) { + sharp(inputJpg).rotate(90).resize(width, height).toBuffer(function(err, buffer) { + if (err) { + throw err; + } else { + assert.notStrictEqual(null, buffer); + deferred.resolve(); + } + }); + } }).add("sharp-file-buffer-sequentialRead", { defer: true, fn: function(deferred) { diff --git a/tests/unit.js b/tests/unit.js index 9e393e07..d5ca4248 100755 --- a/tests/unit.js +++ b/tests/unit.js @@ -12,6 +12,8 @@ var outputJpg = path.join(fixturesPath, "output.jpg"); var inputTiff = path.join(fixturesPath, "G31D.TIF"); // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm var outputTiff = path.join(fixturesPath, "output.tiff"); +var inputJpgWithExif = path.join(fixturesPath, "Landscape_8.jpg"); // https://github.com/recurser/exif-orientation-examples/blob/master/Landscape_8.jpg + async.series([ // Resize with exact crop function(done) { @@ -152,5 +154,64 @@ async.series([ assert(!!err); done(); }); + }, + // Rotate by 90 degrees, respecting output input size + function(done) { + sharp(inputJpg).rotate(90).resize(320, 240).write(outputJpg, function(err) { + if (err) throw err; + imagemagick.identify(outputJpg, function(err, features) { + if (err) throw err; + assert.strictEqual(320, features.width); + assert.strictEqual(240, features.height); + done(); + }); + }); + }, + // Input image has Orientation EXIF tag but do not rotate output + function(done) { + sharp(inputJpgWithExif).resize(320).write(outputJpg, function(err) { + if (err) throw err; + imagemagick.identify(outputJpg, function(err, features) { + if (err) throw err; + assert.strictEqual(320, features.width); + assert.strictEqual(426, features.height); + done(); + }); + }); + }, + // Input image has Orientation EXIF tag value of 8 (270 degrees), auto-rotate + function(done) { + sharp(inputJpgWithExif).rotate().resize(320).write(outputJpg, function(err) { + if (err) throw err; + imagemagick.identify(outputJpg, function(err, features) { + if (err) throw err; + assert.strictEqual(320, features.width); + assert.strictEqual(240, features.height); + done(); + }); + }); + }, + // Attempt to auto-rotate using image that has no EXIF + function(done) { + sharp(inputJpg).rotate().resize(320).write(outputJpg, function(err) { + if (err) throw err; + imagemagick.identify(outputJpg, function(err, features) { + if (err) throw err; + assert.strictEqual(320, features.width); + assert.strictEqual(261, features.height); + done(); + }); + }); + }, + // Rotate to an invalid angle, should fail + function(done) { + var isValid = false; + try { + sharp(inputJpg).rotate(1); + isValid = true; + } catch (e) {} + assert(!isValid); + done(); } + ]);