Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rotation sugar and more #4151

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
21 changes: 19 additions & 2 deletions docs/api-operation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ Mirroring is supported and may infer the use of a flip operation.

The use of `rotate` without an angle will remove the EXIF `Orientation` tag, if any.

Only one rotation can occur per pipeline.
Previous calls to `rotate` in the same pipeline will be ignored.
Only one rotation can occur per pipeline (aside from an initial call without
arguments to orient via EXIF data). Previous calls to `rotate` in the same
pipeline will be ignored.

Multi-page images can only be rotated by 180 degrees.

Expand Down Expand Up @@ -60,6 +61,22 @@ const resizeThenRotate = await sharp(input)
```


## autoOrient
> autoOrient() ⇒ <code>Sharp</code>

Alias for calling `rotate()` with no arguments, which orients the image based
on EXIF orientsion.

This operation is aliased to emphasize its purpose, helping to remove any
confusion between rotation and orientation.


**Example**
```js
const output = await sharp(input).autoOrient().toBuffer();
```


## flip
> flip([flip]) ⇒ <code>Sharp</code>

Expand Down
3 changes: 3 additions & 0 deletions docs/humans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,6 @@ GitHub: https://github.com/project0

Name: Pongsatorn Manusopit
GitHub: https://github.com/ton11797

Name: Don Denton
GitHub: https://github.com/happycollision
2 changes: 1 addition & 1 deletion docs/search-index.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lib/composite.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const blend = {
* @param {number} [images[].input.text.dpi=72] - the resolution (size) at which to render the text. Does not take effect if `height` is specified.
* @param {boolean} [images[].input.text.rgba=false] - set this to true to enable RGBA output. This is useful for colour emoji rendering, or support for Pango markup features like `<span foreground="red">Red!</span>`.
* @param {number} [images[].input.text.spacing=0] - text line height in points. Will use the font line height if none is specified.
* @param {Boolean} [images[].autoOrient=false] - set to true to use EXIF orientation data, if present, to orient the image.
* @param {String} [images[].blend='over'] - how to blend this image with the image below.
* @param {String} [images[].gravity='centre'] - gravity at which to place the overlay.
* @param {Number} [images[].top] - the pixel offset from the top edge.
Expand Down
1 change: 1 addition & 0 deletions lib/constructor.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ const Sharp = function (input, options) {
}
};
this.options.input = this._createInputDescriptor(input, options, { allowStream: true });
this.options.useExifOrientation = !!this.options.input.autoOrient;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if you'd rather do this or rename useExifOrientation. The main part of the pipeline is looking for useExifOrientation to do auto orientation, while the composite part of the pipeline looks for autoOrient.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: I went ahead and replaced options.useExifOrientation with options.input.autoOrient everywhere, and let that cascade into the C++ stuff as well.

return this;
};
Object.setPrototypeOf(Sharp.prototype, stream.Duplex.prototype);
Expand Down
73 changes: 64 additions & 9 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,24 +364,72 @@ declare namespace sharp {
//#region Operation functions

/**
* Rotate the output image by either an explicit angle or auto-orient based on the EXIF Orientation tag.
* 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 positive degree rotation. For example, -450 will produce a 270deg rotation.
* If an angle is provided, it is converted to a valid positive degree rotation.
* For example, `-450` will produce a 270 degree rotation.
*
* When rotating by an angle other than a multiple of 90, the background colour can be provided with the background option.
* When rotating by an angle other than a multiple of 90,
* the background colour 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.
* If no angle is provided, it is determined from the EXIF data.
* Mirroring is supported and may infer the use of a flip operation.
*
* The use of rotate implies the removal of the EXIF Orientation tag, if any.
* The use of `rotate` without an angle will remove the EXIF `Orientation` tag, if any.
Comment on lines +367 to +379
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not only a change to reflect the doc improvements, but also to sync with the docs created in the operation.js file.

*
* Method order is important when both rotating and extracting regions, for example rotate(x).extract(y) will produce a different result to extract(y).rotate(x).
* @param angle angle of rotation. (optional, default auto)
* @param options if present, is an Object with optional attributes.
* Only one rotation can occur per pipeline (aside from an initial call without
* arguments to orient via EXIF data). Previous calls to `rotate` in the same
* pipeline will be ignored.
*
* Multi-page images can only be rotated by 180 degrees.
*
* Method order is important when rotating, resizing and/or extracting regions,
* for example `.rotate(x).extract(y)` will produce a different result to `.extract(y).rotate(x)`.
*
* @example
* const pipeline = sharp()
* .rotate()
* .resize(null, 200)
* .toBuffer(function (err, outputBuffer, info) {
* // outputBuffer contains 200px high JPEG image data,
* // auto-rotated using EXIF Orientation tag
* // info.width and info.height contain the dimensions of the resized image
* });
* readableStream.pipe(pipeline);
*
* @example
* const rotateThenResize = await sharp(input)
* .rotate(90)
* .resize({ width: 16, height: 8, fit: 'fill' })
* .toBuffer();
* const resizeThenRotate = await sharp(input)
* .resize({ width: 16, height: 8, fit: 'fill' })
* .rotate(90)
* .toBuffer();
*
* @param {number} [angle=auto] angle of rotation.
* @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
* @returns A sharp instance that can be used to chain operations
*/
rotate(angle?: number, options?: RotateOptions): Sharp;

/**
* Alias for calling `rotate()` with no arguments, which orients the image based
* on EXIF orientsion.
*
* This operation is aliased to emphasize its purpose, helping to remove any
* confusion between rotation and orientation.
*
* @example
* const output = await sharp(input).autoOrient().toBuffer();
*
* @returns {Sharp}
*/
autoOrient(): Sharp

/**
* Flip the image about the vertical Y axis. This always occurs after rotation, if any.
* The use of flip implies the removal of the EXIF Orientation tag, if any.
Expand Down Expand Up @@ -900,6 +948,13 @@ declare namespace sharp {
}

interface SharpOptions {
/**
* Auto-orient based on the EXIF `Orientation` tag, if present.
* Mirroring is supported and may infer the use of a flip operation.
*
* Using this option will remove the EXIF `Orientation` tag, if any.
*/
autoOrient?: boolean;
/**
* When to abort processing of invalid pixel data, one of (in order of sensitivity):
* 'none' (least), 'truncated', 'error' or 'warning' (most), highers level imply lower levels, invalid metadata will always abort. (optional, default 'warning')
Expand Down
15 changes: 12 additions & 3 deletions lib/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ const align = {
* @private
*/
function _inputOptionsFromObject (obj) {
const { raw, density, limitInputPixels, ignoreIcc, unlimited, sequentialRead, failOn, failOnError, animated, page, pages, subifd } = obj;
return [raw, density, limitInputPixels, ignoreIcc, unlimited, sequentialRead, failOn, failOnError, animated, page, pages, subifd].some(is.defined)
? { raw, density, limitInputPixels, ignoreIcc, unlimited, sequentialRead, failOn, failOnError, animated, page, pages, subifd }
const { raw, density, limitInputPixels, ignoreIcc, unlimited, sequentialRead, failOn, failOnError, animated, page, pages, subifd, autoOrient } = obj;
return [raw, density, limitInputPixels, ignoreIcc, unlimited, sequentialRead, failOn, failOnError, animated, page, pages, subifd, autoOrient].some(is.defined)
? { raw, density, limitInputPixels, ignoreIcc, unlimited, sequentialRead, failOn, failOnError, animated, page, pages, subifd, autoOrient }
: undefined;
}

Expand All @@ -36,6 +36,7 @@ function _inputOptionsFromObject (obj) {
*/
function _createInputDescriptor (input, inputOptions, containerOptions) {
const inputDescriptor = {
autoOrient: false,
failOn: 'warning',
limitInputPixels: Math.pow(0x3FFF, 2),
ignoreIcc: false,
Expand Down Expand Up @@ -93,6 +94,14 @@ function _createInputDescriptor (input, inputOptions, containerOptions) {
throw is.invalidParameterError('failOn', 'one of: none, truncated, error, warning', inputOptions.failOn);
}
}
// autoOrient
if (is.defined(inputOptions.autoOrient)) {
if (is.bool(inputOptions.autoOrient)) {
inputDescriptor.autoOrient = inputOptions.autoOrient;
} else {
throw is.invalidParameterError('autoOrient', 'boolean', inputOptions.autoOrient);
}
}
// Density
if (is.defined(inputOptions.density)) {
if (is.inRange(inputOptions.density, 1, 100000)) {
Expand Down
22 changes: 20 additions & 2 deletions lib/operation.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ const is = require('./is');
*
* The use of `rotate` without an angle will remove the EXIF `Orientation` tag, if any.
*
* Only one rotation can occur per pipeline.
* Previous calls to `rotate` in the same pipeline will be ignored.
* Only one rotation can occur per pipeline (aside from an initial call without
* arguments to orient via EXIF data). Previous calls to `rotate` in the same
* pipeline will be ignored.
*
* Multi-page images can only be rotated by 180 degrees.
*
Expand Down Expand Up @@ -81,6 +82,22 @@ function rotate (angle, options) {
return this;
}

/**
* Alias for calling `rotate()` with no arguments, which orients the image based
* on EXIF orientsion.
*
* This operation is aliased to emphasize its purpose, helping to remove any
* confusion between rotation and orientation.
*
* @example
* const output = await sharp(input).autoOrient().toBuffer();
*
* @returns {Sharp}
*/
function autoOrient () {
return this.rotate();
}

/**
* Mirror the image vertically (up-down) about the x-axis.
* This always occurs before rotation, if any.
Expand Down Expand Up @@ -895,6 +912,7 @@ function modulate (options) {
*/
module.exports = function (Sharp) {
Object.assign(Sharp.prototype, {
autoOrient,
rotate,
flip,
flop,
Expand Down
2 changes: 2 additions & 0 deletions src/common.cc
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ namespace sharp {
descriptor->access = AttrAsBool(input, "sequentialRead") ? VIPS_ACCESS_SEQUENTIAL : VIPS_ACCESS_RANDOM;
// Remove safety features and allow unlimited input
descriptor->unlimited = AttrAsBool(input, "unlimited");
// Use the EXIF orientation to auto orient the image
descriptor->autoOrient = AttrAsBool(input, "autoOrient");
return descriptor;
}

Expand Down
2 changes: 2 additions & 0 deletions src/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ namespace sharp {
struct InputDescriptor { // NOLINT(runtime/indentation_namespace)
std::string name;
std::string file;
bool autoOrient;
char *buffer;
VipsFailOn failOn;
uint64_t limitInputPixels;
Expand Down Expand Up @@ -76,6 +77,7 @@ namespace sharp {
int textAutofitDpi;

InputDescriptor():
autoOrient(false),
buffer(nullptr),
failOn(VIPS_FAIL_ON_WARNING),
limitInputPixels(0x3FFF * 0x3FFF),
Expand Down
42 changes: 32 additions & 10 deletions src/pipeline.cc
Copy link
Contributor Author

@happycollision happycollision Jul 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am commenting on this file only so this conversation can be in a single thread. These thoughts are about the entire PR.

Everything in this PR is backwards-compatible based on documentation, but not in actual effect.

The changes in behavior for .rotate() are evidenced by the failing tests that I added in one of my first commits. I am guessing there will be some people out there who are depending on the old behavior.

In a separate branch (the one I originally created to prove my ideas before making this proposal), I kept the behavior of .rotate() untouched while also introducing a new way of invoking auto-orientation.

I could rewrite a few things in this PR and do the same here. The idea being that if you use .rotate(), the re-orientation and subsequent calls to rotate would produce the old output, while using auto-orient would always invoke the new behavior.

The documentation and runtime warnings could deprecate the use of .rotate(), and encourage autoOrient. Then the code that preserves the old behavior could be removed at the next major.

Between the tests on my old branch which I could pull into this one, and the tests on this branch, I think it’d be fairly easy to juggle these behaviors.

Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ class PipelineWorker : public Napi::AsyncWorker {
// Rotate and flip image according to Exif orientation
std::tie(autoRotation, autoFlip, autoFlop) = CalculateExifRotationAndFlip(sharp::ExifOrientation(image));
image = sharp::RemoveExifOrientation(image);
} else {
rotation = CalculateAngleRotation(baton->angle);
}

rotation = CalculateAngleRotation(baton->angle);

Comment on lines -80 to +83
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it very surprising that pulling this line out of the if/else block didn't make any tests fail. Perhaps it was never needed to begin with?

// Rotate pre-extract
bool const shouldRotateBefore = baton->rotateBeforePreExtract &&
(rotation != VIPS_ANGLE_D0 || autoRotation != VIPS_ANGLE_D0 ||
Expand All @@ -102,18 +102,14 @@ class PipelineWorker : public Napi::AsyncWorker {
image = image.rot(autoRotation);
autoRotation = VIPS_ANGLE_D0;
}
if (autoFlip) {
if (autoFlip != baton->flip) {
image = image.flip(VIPS_DIRECTION_VERTICAL);
autoFlip = false;
} else if (baton->flip) {
image = image.flip(VIPS_DIRECTION_VERTICAL);
baton->flip = false;
}
if (autoFlop) {
if (autoFlop != baton->flop) {
image = image.flip(VIPS_DIRECTION_HORIZONTAL);
autoFlop = false;
} else if (baton->flop) {
image = image.flip(VIPS_DIRECTION_HORIZONTAL);
baton->flop = false;
}
if (rotation != VIPS_ANGLE_D0) {
Expand Down Expand Up @@ -405,11 +401,11 @@ class PipelineWorker : public Napi::AsyncWorker {
image = image.rot(autoRotation);
}
// Mirror vertically (up-down) about the x-axis
if (baton->flip || autoFlip) {
if (baton->flip != autoFlip) {
image = image.flip(VIPS_DIRECTION_VERTICAL);
}
// Mirror horizontally (left-right) about the y-axis
if (baton->flop || autoFlop) {
if (baton->flop != autoFlop) {
image = image.flip(VIPS_DIRECTION_HORIZONTAL);
}
// Rotate post-extract 90-angle
Expand Down Expand Up @@ -640,6 +636,32 @@ class PipelineWorker : public Napi::AsyncWorker {
composite->input->access = access;
std::tie(compositeImage, compositeImageType) = sharp::OpenInput(composite->input);
compositeImage = sharp::EnsureColourspace(compositeImage, baton->colourspacePipeline);

if (composite->input->autoOrient) {
// Calculate angle of rotation
VipsAngle compositeAutoRotation = VIPS_ANGLE_D0;
bool compositeAutoFlip = false;
bool compositeAutoFlop = false;

// Rotate and flip image according to Exif orientation
std::tie(compositeAutoRotation, compositeAutoFlip, compositeAutoFlop) =
CalculateExifRotationAndFlip(sharp::ExifOrientation(compositeImage));

compositeImage = sharp::RemoveExifOrientation(compositeImage);

if (compositeAutoRotation != VIPS_ANGLE_D0) {
compositeImage = compositeImage.rot(compositeAutoRotation);
}
// Mirror vertically (up-down) about the x-axis
if (compositeAutoFlip) {
compositeImage = compositeImage.flip(VIPS_DIRECTION_VERTICAL);
}
// Mirror horizontally (left-right) about the y-axis
if (compositeAutoFlop) {
compositeImage = compositeImage.flip(VIPS_DIRECTION_HORIZONTAL);
}
}

// Verify within current dimensions
if (compositeImage.width() > image.width() || compositeImage.height() > image.height()) {
throw vips::VError("Image to composite must have same dimensions or smaller");
Expand Down
Binary file added test/fixtures/expected/Landscape_1_flip-out.jpg
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment on all of these images: I don't know if you want me to pair this down, but I am brute-forcing via tests here.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_1_flop-out.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_2_flip-out.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_2_flop-out.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_3_flip-out.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_3_flop-out.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_4_flip-out.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_4_flop-out.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/Landscape_5_flip-out.jpg
Binary file added test/fixtures/expected/Landscape_5_flop-out.jpg
Binary file added test/fixtures/expected/Landscape_6_flip-out.jpg
Binary file added test/fixtures/expected/Landscape_6_flop-out.jpg
Binary file added test/fixtures/expected/Landscape_7_flip-out.jpg
Binary file added test/fixtures/expected/Landscape_7_flop-out.jpg
Binary file added test/fixtures/expected/Landscape_8_flip-out.jpg
Binary file added test/fixtures/expected/Landscape_8_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_1_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_1_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_2_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_2_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_3_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_3_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_4_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_4_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_5_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_5_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_6_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_6_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_7_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_7_flop-out.jpg
Binary file added test/fixtures/expected/Portrait_8_flip-out.jpg
Binary file added test/fixtures/expected/Portrait_8_flop-out.jpg
Binary file added test/fixtures/expected/composite-autoOrient.jpg
15 changes: 15 additions & 0 deletions test/unit/composite.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ describe('composite', () => {
fixtures.assertMaxColourDistance(actual, expected);
});

it('autoOrient', (done) => {
const filename = 'composite-autoOrient.jpg';
const exifImg = fixtures.inputJpgWithExif;
const actual = fixtures.path(`output.${filename}`);
const expected = fixtures.expected(filename);
sharp({ create: { width: 600, height: 600, channels: 4, background: { ...red, alpha: 1 } } })
.composite([{
input: exifImg,
autoOrient: true
}])
.toFile(actual).then(() => {
fixtures.assertSimilar(actual, expected, done);
});
});

it('zero offset', done => {
sharp(fixtures.inputJpg)
.resize(80)
Expand Down
Loading