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
1 change: 1 addition & 0 deletions docs/api-composite.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ and https://www.cairographics.org/operators/
| [images[].input.text.dpi] | <code>number</code> | <code>72</code> | the resolution (size) at which to render the text. Does not take effect if `height` is specified. |
| [images[].input.text.rgba] | <code>boolean</code> | <code>false</code> | 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>`. |
| [images[].input.text.spacing] | <code>number</code> | <code>0</code> | text line height in points. Will use the font line height if none is specified. |
| [images[].autoOrient] | <code>Boolean</code> | <code>false</code> | set to true to use EXIF orientation data, if present, to orient the image. |
| [images[].blend] | <code>String</code> | <code>&#x27;over&#x27;</code> | how to blend this image with the image below. |
| [images[].gravity] | <code>String</code> | <code>&#x27;centre&#x27;</code> | gravity at which to place the overlay. |
| [images[].top] | <code>Number</code> | | the pixel offset from the top edge. |
Expand Down
55 changes: 34 additions & 21 deletions docs/api-operation.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
## rotate
> rotate([angle], [options]) ⇒ <code>Sharp</code>

Rotate the output image by either an explicit angle
or auto-orient based on the EXIF `Orientation` tag.
Rotate the output image.

If an angle is provided, it is converted to a valid positive degree rotation.
The provided angle 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.

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` without an angle will remove the EXIF `Orientation` tag, if any.
For backwards compatibility, if no angle is provided, `.autoOrient()` will be called.

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 All @@ -35,18 +32,6 @@ for example `.rotate(x).extract(y)` will produce a different result to `.extract
| [options] | <code>Object</code> | | if present, is an Object with optional attributes. |
| [options.background] | <code>string</code> \| <code>Object</code> | <code>&quot;\&quot;#000000\&quot;&quot;</code> | parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha. |

**Example**
```js
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**
```js
const rotateThenResize = await sharp(input)
Expand All @@ -60,6 +45,34 @@ const resizeThenRotate = await sharp(input)
```


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

Auto-orient based on the EXIF `Orientation` tag, then remove the tag.
Copy link
Contributor Author

@happycollision happycollision Jul 15, 2024

Choose a reason for hiding this comment

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

It occurs to me now that I am trying to test this new version of Sharp with @sveltejs/enhanced-img that the metadata is not adjusted in time for some useful operations to take place. For example:

sharp(img, {autoOrient: true]}).metadata()

The above will show the metadata before any auto-orientation takes place, which seems like a headache from the perspective of someone using the tool. If I am asking sharp to auto-orient the image, why would I want the old width and height?

I see several ways to work this issue:

  1. No change. All metadata is from the input state of the image, the example getNormalSize function in the docs is all anyone needs.
  2. If autoOrient is true, drop "orientation" and swap width and height as necessary from metadata.
  3. Add an option to .metadata({ autoOrient: boolean }) that opts you in to view the adjusted metadata.
  4. Export a function autoOrientMetadata(metadata) that devs can use as needed on metadata objects.
  5. Add an appliedOrientation property that reports the width and height again, changing them if necessary based on exif orientation:
{
  format: 'jpeg',
  width: 4032,
  height: 3024,
  space: 'srgb',
  channels: 3,
  depth: 'uchar',
  density: 72,
  chromaSubsampling: '4:2:0',
  isProgressive: false,
  resolutionUnit: 'inch',
  hasProfile: false,
  hasAlpha: false,
  orientation: 6,
+ appliedOrientation: {
+   width: 3024,
+   height: 4032
+ }
}

It seems like a useful thing to just include that metadata so developers can see it when they inspect the thing and remain blissfully ignorant about which transforms are needed for each orientation.

Copy link
Owner

Choose a reason for hiding this comment

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

I like the idea of adding extra fields to the metadata that include the dimensions after applying orientation, which is closest to option 5 here. There have been a few people ask questions along the lines of "why are the metadata dimensions not what I expect?" and this would help answer it.

Mirroring is supported and may infer the use of a flip operation.

Previous or subsequent use of `rotate(angle)` and either `flip()` or `flop()`
will logically occur after auto-orientation, regardless of call order.


**Example**
```js
const output = await sharp(input).autoOrient().toBuffer();
```
**Example**
```js
const pipeline = sharp()
.autoOrient()
.resize(null, 200)
.toBuffer(function (err, outputBuffer, info) {
// outputBuffer contains 200px high JPEG image data,
// auto-oriented using EXIF Orientation tag
// info.width and info.height contain the dimensions of the resized image
});
readableStream.pipe(pipeline);
```


## 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: 0 additions & 1 deletion lib/constructor.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,6 @@ const Sharp = function (input, options) {
canvas: 'crop',
position: 0,
resizeBackground: [0, 0, 0, 255],
useExifOrientation: false,
angle: 0,
rotationAngle: 0,
rotationBackground: [0, 0, 0, 255],
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
63 changes: 39 additions & 24 deletions lib/operation.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,26 @@ 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.
* Rotate the output image.
*
* If an angle is provided, it is converted to a valid positive degree rotation.
* The provided angle 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.
*
* 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` without an angle will remove the EXIF `Orientation` tag, if any.
* For backwards compatibility, if no angle is provided, `.autoOrient()` will be called.
*
* 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.
*
* 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' })
Expand All @@ -57,12 +43,12 @@ const is = require('./is');
* @throws {Error} Invalid parameters
*/
function rotate (angle, options) {
if (this.options.useExifOrientation || this.options.angle || this.options.rotationAngle) {
if (!is.defined(angle)) { return this.autoOrient(); }

if (this.options.angle || this.options.rotationAngle) {
this.options.debuglog('ignoring previous rotate options');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This warning says that previous rotations will be ignored, but that isn't actually true unless you reset options.angle and options.rotationAngle to 0 here. Unless you do, these two sets of commands will produce a different output:

sharp(img)
  .rotate(90) // cardinal angle
  .rotate(5)
// output will be rotated 95 degrees

sharp(img)
  .rotate(91) // non-cardinal angle
  .rotate(5)
// output will be rotated 5 degrees

Want me to throw in a fix for that? Or alter the warning? Or just leave as is?

Suggested change
this.options.debuglog('ignoring previous rotate options');
this.options.debuglog('ignoring previous rotate options');
this.options.angle = 0;
this.options.rotationAngle = 0;

Copy link
Owner

Choose a reason for hiding this comment

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

Yes please to fixing this at the same time.

}
if (!is.defined(angle)) {
this.options.useExifOrientation = true;
} else if (is.integer(angle) && !(angle % 90)) {
if (is.integer(angle) && !(angle % 90)) {
this.options.angle = angle;
} else if (is.number(angle)) {
this.options.rotationAngle = angle;
Expand All @@ -81,6 +67,34 @@ function rotate (angle, options) {
return this;
}

/**
* Auto-orient based on the EXIF `Orientation` tag, then remove the tag.
* Mirroring is supported and may infer the use of a flip operation.
*
* Previous or subsequent use of `rotate(angle)` and either `flip()` or `flop()`
* will logically occur after auto-orientation, regardless of call order.
*
* @example
* const output = await sharp(input).autoOrient().toBuffer();
*
* @example
* const pipeline = sharp()
* .autoOrient()
* .resize(null, 200)
* .toBuffer(function (err, outputBuffer, info) {
* // outputBuffer contains 200px high JPEG image data,
* // auto-oriented using EXIF Orientation tag
* // info.width and info.height contain the dimensions of the resized image
* });
* readableStream.pipe(pipeline);
*
* @returns {Sharp}
*/
function autoOrient () {
this.options.input.autoOrient = true;
return this;
}

/**
* Mirror the image vertically (up-down) about the x-axis.
* This always occurs before rotation, if any.
Expand Down Expand Up @@ -895,6 +909,7 @@ function modulate (options) {
*/
module.exports = function (Sharp) {
Object.assign(Sharp.prototype, {
autoOrient,
rotate,
flip,
flop,
Expand Down
2 changes: 1 addition & 1 deletion lib/resize.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const mapFitToCanvas = {
* @private
*/
function isRotationExpected (options) {
return (options.angle % 360) !== 0 || options.useExifOrientation === true || options.rotationAngle !== 0;
return (options.angle % 360) !== 0 || options.input.autoOrient === true || options.rotationAngle !== 0;
}

/**
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
Loading