diff --git a/docs/api-operation.md b/docs/api-operation.md index 34391dc8..3e91be97 100644 --- a/docs/api-operation.md +++ b/docs/api-operation.md @@ -5,9 +5,12 @@ Rotate the output image by either an explicit angle or auto-orient based on the EXIF `Orientation` tag. -If an angle is provided, it is converted to a valid 90/180/270deg rotation. +If an angle is provided, it is converted to a valid positive degree rotation. For example, `-450` will produce a 270deg rotation. +If an angle that is not a multiple of 90 is provided, the color of the +background color can be provided with the `background` option. + If no angle is provided, it is determined from the EXIF data. Mirroring is supported and may infer the use of a flip operation. @@ -19,6 +22,8 @@ for example `rotate(x).extract(y)` will produce a different result to `extract(y ### Parameters - `angle` **[Number][1]** angle of rotation, must be a multiple of 90. (optional, default `auto`) +- `options` **[Object][2]?** if present, is an Object with optional attributes. + - `options.background` **([String][3] \| [Object][2])** parsed by the [color][4] module to extract values for red, green, blue and alpha. (optional, default `"#000000"`) ### Examples @@ -34,7 +39,7 @@ const pipeline = sharp() readableStream.pipe(pipeline); ``` -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -45,7 +50,7 @@ The use of `flip` implies the removal of the EXIF `Orientation` tag, if any. ### Parameters -- `flip` **[Boolean][3]** (optional, default `true`) +- `flip` **[Boolean][6]** (optional, default `true`) Returns **Sharp** @@ -56,7 +61,7 @@ The use of `flop` implies the removal of the EXIF `Orientation` tag, if any. ### Parameters -- `flop` **[Boolean][3]** (optional, default `true`) +- `flop` **[Boolean][6]** (optional, default `true`) Returns **Sharp** @@ -74,7 +79,7 @@ Separate control over the level of sharpening in "flat" and "jagged" areas is av - `jagged` **[Number][1]** the level of sharpening to apply to "jagged" areas. (optional, default `2.0`) -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -88,7 +93,7 @@ When used without parameters the default window is 3x3. - `size` **[Number][1]** square mask size: size x size (optional, default `3`) -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -103,7 +108,7 @@ When a `sigma` is provided, performs a slower, more accurate Gaussian blur. - `sigma` **[Number][1]?** a value between 0.3 and 1000 representing the sigma of the Gaussian mask, where `sigma = 1 + radius / 2`. -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -113,7 +118,7 @@ Merge alpha transparency channel, if any, with `background`. ### Parameters -- `flatten` **[Boolean][3]** (optional, default `true`) +- `flatten` **[Boolean][6]** (optional, default `true`) Returns **Sharp** @@ -130,7 +135,7 @@ when applying a gamma correction. - `gamma` **[Number][1]** value between 1.0 and 3.0. (optional, default `2.2`) -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -140,7 +145,7 @@ Produce the "negative" of the image. ### Parameters -- `negate` **[Boolean][3]** (optional, default `true`) +- `negate` **[Boolean][6]** (optional, default `true`) Returns **Sharp** @@ -150,7 +155,7 @@ Enhance output image contrast by stretching its luminance to cover the full dyna ### Parameters -- `normalise` **[Boolean][3]** (optional, default `true`) +- `normalise` **[Boolean][6]** (optional, default `true`) Returns **Sharp** @@ -160,7 +165,7 @@ Alternative spelling of normalise. ### Parameters -- `normalize` **[Boolean][3]** (optional, default `true`) +- `normalize` **[Boolean][6]** (optional, default `true`) Returns **Sharp** @@ -170,10 +175,10 @@ Convolve the image with the specified kernel. ### Parameters -- `kernel` **[Object][4]** +- `kernel` **[Object][2]** - `kernel.width` **[Number][1]** width of the kernel in pixels. - `kernel.height` **[Number][1]** width of the kernel in pixels. - - `kernel.kernel` **[Array][5]<[Number][1]>** Array of length `width*height` containing the kernel values. + - `kernel.kernel` **[Array][7]<[Number][1]>** Array of length `width*height` containing the kernel values. - `kernel.scale` **[Number][1]** the scale of the kernel in pixels. (optional, default `sum`) - `kernel.offset` **[Number][1]** the offset of the kernel in pixels. (optional, default `0`) @@ -193,7 +198,7 @@ sharp(input) }); ``` -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -204,12 +209,12 @@ Any pixel value greather than or equal to the threshold value will be set to 255 ### Parameters - `threshold` **[Number][1]** a value in the range 0-255 representing the level at which the threshold will be applied. (optional, default `128`) -- `options` **[Object][4]?** - - `options.greyscale` **[Boolean][3]** convert to single channel greyscale. (optional, default `true`) - - `options.grayscale` **[Boolean][3]** alternative spelling for greyscale. (optional, default `true`) +- `options` **[Object][2]?** + - `options.greyscale` **[Boolean][6]** convert to single channel greyscale. (optional, default `true`) + - `options.grayscale` **[Boolean][6]** alternative spelling for greyscale. (optional, default `true`) -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -222,16 +227,16 @@ the selected bitwise boolean `operation` between the corresponding pixels of the ### Parameters -- `operand` **([Buffer][6] \| [String][7])** Buffer containing image data or String containing the path to an image file. -- `operator` **[String][7]** one of `and`, `or` or `eor` to perform that bitwise operation, like the C logic operators `&`, `|` and `^` respectively. -- `options` **[Object][4]?** - - `options.raw` **[Object][4]?** describes operand when using raw pixel data. +- `operand` **([Buffer][8] \| [String][3])** Buffer containing image data or String containing the path to an image file. +- `operator` **[String][3]** one of `and`, `or` or `eor` to perform that bitwise operation, like the C logic operators `&`, `|` and `^` respectively. +- `options` **[Object][2]?** + - `options.raw` **[Object][2]?** describes operand when using raw pixel data. - `options.raw.width` **[Number][1]?** - `options.raw.height` **[Number][1]?** - `options.raw.channels` **[Number][1]?** -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** @@ -245,20 +250,22 @@ Apply the linear formula a \* input + b to the image (levels adjustment) - `b` **[Number][1]** offset (optional, default `0.0`) -- Throws **[Error][2]** Invalid parameters +- Throws **[Error][5]** Invalid parameters Returns **Sharp** [1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number -[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error +[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object -[3]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean +[3]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[4]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object +[4]: https://www.npmjs.org/package/color -[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array +[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error -[6]: https://nodejs.org/api/buffer.html +[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean -[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String +[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array + +[8]: https://nodejs.org/api/buffer.html diff --git a/lib/constructor.js b/lib/constructor.js index bcdf21e5..5feca120 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -108,6 +108,8 @@ const Sharp = function (input, options) { embed: 0, useExifOrientation: false, angle: 0, + rotationAngle: 0, + rotationBackground: [0, 0, 0, 255], rotateBeforePreExtract: false, flip: false, flop: false, diff --git a/lib/operation.js b/lib/operation.js index 94ed74be..8c0b54b7 100644 --- a/lib/operation.js +++ b/lib/operation.js @@ -1,14 +1,18 @@ 'use strict'; +const color = require('color'); const is = require('./is'); /** * Rotate the output image by either an explicit angle * or auto-orient based on the EXIF `Orientation` tag. * - * If an angle is provided, it is converted to a valid 90/180/270deg rotation. + * If an angle is provided, it is converted to a valid positive degree rotation. * For example, `-450` will produce a 270deg rotation. * + * If an angle that is not a multiple of 90 is provided, the color of the + * background color can be provided with the `background` option. + * * If no angle is provided, it is determined from the EXIF data. * Mirroring is supported and may infer the use of a flip operation. * @@ -29,16 +33,29 @@ const is = require('./is'); * readableStream.pipe(pipeline); * * @param {Number} [angle=auto] angle of rotation, must be a multiple of 90. + * @param {Object} [options] - if present, is an Object with optional attributes. + * @param {String|Object} [options.background="#000000"] parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. * @returns {Sharp} * @throws {Error} Invalid parameters */ -function rotate (angle) { +function rotate (angle, options) { if (!is.defined(angle)) { this.options.useExifOrientation = true; } else if (is.integer(angle) && !(angle % 90)) { this.options.angle = angle; + } else if (is.number(angle)) { + this.options.rotationAngle = angle; + if (is.object(options) && options.background) { + const backgroundColour = color(options.background); + this.options.rotationBackground = [ + backgroundColour.red(), + backgroundColour.green(), + backgroundColour.blue(), + Math.round(backgroundColour.alpha() * 255) + ]; + } } else { - throw new Error('Unsupported angle: angle must be a positive/negative multiple of 90 ' + angle); + throw new Error('Unsupported angle: angle must be a number.'); } return this; } diff --git a/src/pipeline.cc b/src/pipeline.cc index 4c6d2bbf..366adf8c 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -476,6 +476,13 @@ class PipelineWorker : public Nan::AsyncWorker { } } + // Rotate by degree + if (baton->rotationAngle != 0.0) { + std::vector background; + std::tie(image, background) = sharp::ApplyAlpha(image, baton->rotationBackground); + image = image.rotate(baton->rotationAngle, VImage::option()->set("background", background)); + } + // Post extraction if (baton->topOffsetPost != -1) { image = image.extract_area( @@ -1187,6 +1194,12 @@ NAN_METHOD(pipeline) { baton->normalise = AttrTo(options, "normalise"); baton->useExifOrientation = AttrTo(options, "useExifOrientation"); baton->angle = AttrTo(options, "angle"); + baton->rotationAngle = AttrTo(options, "rotationAngle"); + // Rotation background colour + v8::Local rotationBackground = AttrAs(options, "rotationBackground"); + for (unsigned int i = 0; i < 4; i++) { + baton->rotationBackground[i] = AttrTo(rotationBackground, i); + } baton->rotateBeforePreExtract = AttrTo(options, "rotateBeforePreExtract"); baton->flip = AttrTo(options, "flip"); baton->flop = AttrTo(options, "flop"); diff --git a/src/pipeline.h b/src/pipeline.h index 6cfdf2c5..357b4c49 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -89,6 +89,8 @@ struct PipelineBaton { bool normalise; bool useExifOrientation; int angle; + double rotationAngle; + double rotationBackground[4]; bool rotateBeforePreExtract; bool flip; bool flop; @@ -180,6 +182,7 @@ struct PipelineBaton { normalise(false), useExifOrientation(false), angle(0), + rotationAngle(0.0), flip(false), flop(false), extendTop(0), diff --git a/test/fixtures/expected/rotate-solid-bg.jpg b/test/fixtures/expected/rotate-solid-bg.jpg new file mode 100644 index 00000000..2110578f Binary files /dev/null and b/test/fixtures/expected/rotate-solid-bg.jpg differ diff --git a/test/fixtures/expected/rotate-transparent-bg.png b/test/fixtures/expected/rotate-transparent-bg.png new file mode 100644 index 00000000..62fefc10 Binary files /dev/null and b/test/fixtures/expected/rotate-transparent-bg.png differ diff --git a/test/unit/rotate.js b/test/unit/rotate.js index 6ddc9587..43b12873 100644 --- a/test/unit/rotate.js +++ b/test/unit/rotate.js @@ -23,6 +23,33 @@ describe('Rotation', function () { }); }); + it('Rotate by 30 degrees with semi-transparent background', function (done) { + sharp(fixtures.inputJpg) + .rotate(30, {background: { r: 255, g: 0, b: 0, alpha: 0.5 }}) + .resize(320) + .png() + .toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + assert.strictEqual(408, info.width); + assert.strictEqual(386, info.height); + fixtures.assertSimilar(fixtures.expected('rotate-transparent-bg.png'), data, done); + }); + }); + + it('Rotate by 30 degrees with solid background', function (done) { + sharp(fixtures.inputJpg) + .rotate(30, {background: { r: 255, g: 0, b: 0, alpha: 0.5 }}) + .resize(320) + .toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(408, info.width); + assert.strictEqual(386, info.height); + fixtures.assertSimilar(fixtures.expected('rotate-solid-bg.jpg'), data, done); + }); + }); + it('Rotate by 90 degrees, respecting output input size', function (done) { sharp(fixtures.inputJpg).rotate(90).resize(320, 240).toBuffer(function (err, data, info) { if (err) throw err; @@ -34,6 +61,17 @@ describe('Rotation', function () { }); }); + it('Rotate by 30 degrees, respecting output input size', function (done) { + sharp(fixtures.inputJpg).rotate(30).resize(320, 240).toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual(true, data.length > 0); + assert.strictEqual('jpeg', info.format); + assert.strictEqual(397, info.width); + assert.strictEqual(368, info.height); + done(); + }); + }); + [-3690, -450, -90, 90, 450, 3690].forEach(function (angle) { it('Rotate by any 90-multiple angle (' + angle + 'deg)', function (done) { sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) { @@ -45,6 +83,17 @@ describe('Rotation', function () { }); }); + [-3750, -510, -150, 30, 390, 3630].forEach(function (angle) { + it('Rotate by any 30-multiple angle (' + angle + 'deg)', function (done) { + sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual(397, info.width); + assert.strictEqual(368, info.height); + done(); + }); + }); + }); + [-3780, -540, 0, 180, 540, 3780].forEach(function (angle) { it('Rotate by any 180-multiple angle (' + angle + 'deg)', function (done) { sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) { @@ -74,6 +123,24 @@ describe('Rotation', function () { }); }); + it('Rotate by 315 degrees, square output ignoring aspect ratio', function (done) { + sharp(fixtures.inputJpg) + .resize(240, 240) + .ignoreAspectRatio() + .rotate(315) + .toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual(339, info.width); + assert.strictEqual(339, info.height); + sharp(data).metadata(function (err, metadata) { + if (err) throw err; + assert.strictEqual(339, metadata.width); + assert.strictEqual(339, metadata.height); + done(); + }); + }); + }); + it('Rotate by 270 degrees, rectangular output ignoring aspect ratio', function (done) { sharp(fixtures.inputJpg) .resize(320, 240) @@ -92,6 +159,24 @@ describe('Rotation', function () { }); }); + it('Rotate by 30 degrees, rectangular output ignoring aspect ratio', function (done) { + sharp(fixtures.inputJpg) + .resize(320, 240) + .ignoreAspectRatio() + .rotate(30) + .toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual(397, info.width); + assert.strictEqual(368, info.height); + sharp(data).metadata(function (err, metadata) { + if (err) throw err; + assert.strictEqual(397, metadata.width); + assert.strictEqual(368, metadata.height); + done(); + }); + }); + }); + it('Input image has Orientation EXIF tag but do not rotate output', function (done) { sharp(fixtures.inputJpgWithExif) .resize(320) @@ -185,9 +270,9 @@ describe('Rotation', function () { }); }); - it('Rotate to an invalid angle, should fail', function () { + it('Rotate with a string argument, should fail', function () { assert.throws(function () { - sharp(fixtures.inputJpg).rotate(1); + sharp(fixtures.inputJpg).rotate('not-a-number'); }); });