Skip to content

Commit

Permalink
Rewrite handling EXIF orientation — add tests, make it plugin-indepen…
Browse files Browse the repository at this point in the history
…dent (#875)

* Write tests for handling EXIF orientation

* Slightly improve docs for a private function

* Rewrite handling EXIF orientation

Do not rely on .rotate() and .mirror() methods, which are defined
in plugins.  Instead, implement bitmap transformation functions,
which then can be applied to move pixels around.  This approach has
two main benefits: no plugins are required, and everything happens
in one pass.
  • Loading branch information
skalee committed Apr 14, 2020
1 parent 44ce60b commit b038cba
Show file tree
Hide file tree
Showing 2 changed files with 141 additions and 34 deletions.
151 changes: 117 additions & 34 deletions packages/core/src/utils/image-bitmap.js
Expand Up @@ -25,44 +25,127 @@ function getMIMEFromBuffer(buffer, path) {
}

/*
* Automagically rotates an image based on its EXIF data (if present)
* @param img a constants object
* Obtains image orientation from EXIF metadata.
*
* @param img {Jimp} a Jimp image object
* @returns {number} a number 1-8 representing EXIF orientation,
* in particular 1 if orientation tag is missing
*/
function exifRotate(img) {
const exif = img._exif;

if (exif && exif.tags && exif.tags.Orientation) {
switch (img._exif.tags.Orientation) {
case 1: // Horizontal (normal)
// do nothing
break;
case 2: // Mirror horizontal
img.mirror(true, false);
break;
case 3: // Rotate 180
img.rotate(180);
break;
case 4: // Mirror vertical
img.mirror(false, true);
break;
case 5: // Mirror horizontal and rotate 270 CW
img.rotate(-90).mirror(true, false);
break;
case 6: // Rotate 90 CW
img.rotate(-90);
break;
case 7: // Mirror horizontal and rotate 90 CW
img.rotate(90).mirror(true, false);
break;
case 8: // Rotate 270 CW
img.rotate(-270);
break;
default:
break;
function getExifOrientation(img) {
return (img._exif && img._exif.tags && img._exif.tags.Orientation) || 1;
}

/**
* Returns a function which translates EXIF-rotated coordinates into
* non-rotated ones.
*
* Transformation reference: http://sylvana.net/jpegcrop/exif_orientation.html.
*
* @param img {Jimp} a Jimp image object
* @returns {function} transformation function for transformBitmap().
*/
function getExifOrientationTransformation(img) {
const w = img.getWidth();
const h = img.getHeight();

switch (getExifOrientation(img)) {
case 1: // Horizontal (normal)
// does not need to be supported here
return null;

case 2: // Mirror horizontal
return function(x, y) {
return [w - x - 1, y];
};

case 3: // Rotate 180
return function(x, y) {
return [w - x - 1, h - y - 1];
};

case 4: // Mirror vertical
return function(x, y) {
return [x, h - y - 1];
};

case 5: // Mirror horizontal and rotate 270 CW
return function(x, y) {
return [y, x];
};

case 6: // Rotate 90 CW
return function(x, y) {
return [y, h - x - 1];
};

case 7: // Mirror horizontal and rotate 90 CW
return function(x, y) {
return [w - y - 1, h - x - 1];
};

case 8: // Rotate 270 CW
return function(x, y) {
return [w - y - 1, x];
};

default:
return null;
}
}

/*
* Transforms bitmap in place (moves pixels around) according to given
* transformation function.
*
* @param img {Jimp} a Jimp image object, which bitmap is supposed to
* be transformed
* @param width {number} bitmap width after the transformation
* @param height {number} bitmap height after the transformation
* @param transformation {function} transformation function which defines pixel
* mapping between new and source bitmap. It takes a pair of coordinates
* in the target, and returns a respective pair of coordinates in
* the source bitmap, i.e. has following form:
* `function(new_x, new_y) { return [src_x, src_y] }`.
*/
function transformBitmap(img, width, height, transformation) {
// Underscore-prefixed values are related to the source bitmap
// Their counterparts with no prefix are related to the target bitmap
const _data = img.bitmap.data;
const _width = img.bitmap.width;

const data = Buffer.alloc(_data.length);

for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const [_x, _y] = transformation(x, y);

const idx = (width * y + x) << 2;
const _idx = (_width * _y + _x) << 2;

const pixel = _data.readUInt32BE(_idx);
data.writeUInt32BE(pixel, idx);
}
}

return img;
img.bitmap.data = data;
img.bitmap.width = width;
img.bitmap.height = height;
}

/*
* Automagically rotates an image based on its EXIF data (if present).
* @param img {Jimp} a Jimp image object
*/
function exifRotate(img) {
if (getExifOrientation(img) < 2) return;

const transformation = getExifOrientationTransformation(img);
const swapDimensions = getExifOrientation(img) > 4;

const newWidth = swapDimensions ? img.bitmap.height : img.bitmap.width;
const newHeight = swapDimensions ? img.bitmap.width : img.bitmap.height;

transformBitmap(img, newWidth, newHeight, transformation);
}

// parses a bitmap from the constructor to the JIMP bitmap property
Expand Down
24 changes: 24 additions & 0 deletions packages/jimp/test/exif-rotation.test.js
@@ -0,0 +1,24 @@
import { Jimp, getTestDir } from '@jimp/test-utils';

import configure from '@jimp/custom';

const jimp = configure({ plugins: [] }, Jimp);

describe('EXIF orientation', () => {
for (let orientation = 1; orientation <= 8; orientation++) {
it(`is fixed when EXIF orientation is ${orientation}`, async () => {
const regularImg = await imageWithOrientation(1);
const orientedImg = await imageWithOrientation(orientation);

orientedImg.getWidth().should.be.equal(regularImg.getWidth());
orientedImg.getHeight().should.be.equal(regularImg.getHeight());
Jimp.distance(regularImg, orientedImg).should.lessThan(0.07);
});
}
});

function imageWithOrientation(orientation) {
const imageName = `Landscape_${orientation}.jpg`;
const path = getTestDir(__dirname) + '/images/exif-orientation/' + imageName;
return jimp.read(path);
}

0 comments on commit b038cba

Please sign in to comment.