From b038cba64ee72224efe0fe0f9e8f8c8fce0e3711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Ska=C5=82acki?= Date: Tue, 14 Apr 2020 17:39:21 +0200 Subject: [PATCH] =?UTF-8?q?Rewrite=20handling=20EXIF=20orientation=20?= =?UTF-8?q?=E2=80=94=20add=20tests,=20make=20it=20plugin-independent=20(#8?= =?UTF-8?q?75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- packages/core/src/utils/image-bitmap.js | 151 ++++++++++++++++++----- packages/jimp/test/exif-rotation.test.js | 24 ++++ 2 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 packages/jimp/test/exif-rotation.test.js diff --git a/packages/core/src/utils/image-bitmap.js b/packages/core/src/utils/image-bitmap.js index 7b51e0214..875079e97 100644 --- a/packages/core/src/utils/image-bitmap.js +++ b/packages/core/src/utils/image-bitmap.js @@ -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 diff --git a/packages/jimp/test/exif-rotation.test.js b/packages/jimp/test/exif-rotation.test.js new file mode 100644 index 000000000..83b476227 --- /dev/null +++ b/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); +}