From eb203de714f7652db94265332a746c2c7573fc04 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Thu, 30 Aug 2018 18:30:36 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Added=20ability=20to=20resize=20and=20?= =?UTF-8?q?compress=20images=20on=20upload=20(#9837)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs #4453 * On by default * Added config to disable resizing * Added basic image optimization processing * Added dep: sharp (optional dep) * Added resize middleware * Take care of rotation based on EXIF information * Removed all meta data from optimised image * Added handling if sharp could not get installed * Do not read ext twice - optimisation * Do not call sharp if config is disabled * Do not remove the original image which was uploaded (store 2 images) * Support of `req.files` for internal logic * Disabled cache to enable file removal on Windows --- ghost/image-transform/lib/transform.js | 50 ++++++++++ ghost/image-transform/test/transform.test.js | 97 ++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 ghost/image-transform/lib/transform.js create mode 100644 ghost/image-transform/test/transform.test.js diff --git a/ghost/image-transform/lib/transform.js b/ghost/image-transform/lib/transform.js new file mode 100644 index 0000000000..5a3549af64 --- /dev/null +++ b/ghost/image-transform/lib/transform.js @@ -0,0 +1,50 @@ +const Promise = require('bluebird'); +const common = require('../common'); + +/** + * @NOTE: Sharp cannot operate on the same image path, that's why we have to use in & out paths. + * + * We currently can't enable compression or having more config options, because of + * https://github.com/lovell/sharp/issues/1360. + */ +const process = (options = {}) => { + let sharp, img; + + try { + sharp = require('sharp'); + + // @NOTE: workaround for Windows as libvips keeps a reference to the input file + // which makes it impossible to fs.unlink() it on cleanup stage + sharp.cache(false); + img = sharp(options.in); + } catch (err) { + return Promise.reject(new common.errors.InternalServerError({ + message: 'Sharp wasn\'t installed', + code: 'SHARP_INSTALLATION', + err: err + })); + } + + return img.metadata() + .then((metadata) => { + if (metadata.width > options.width) { + img.resize(options.width); + } + + // CASE: if you call `rotate` it will automatically remove the orientation (and all other meta data) and rotates + // based on the orientation. It does not rotate if no orientation is set. + img.rotate(); + }) + .then(() => { + return img.toFile(options.out); + }) + .catch((err) => { + throw new common.errors.InternalServerError({ + message: 'Unable to manipulate image.', + err: err, + code: 'IMAGE_PROCESSING' + }); + }); +}; + +module.exports.process = process; diff --git a/ghost/image-transform/test/transform.test.js b/ghost/image-transform/test/transform.test.js new file mode 100644 index 0000000000..c9fe6be047 --- /dev/null +++ b/ghost/image-transform/test/transform.test.js @@ -0,0 +1,97 @@ +const should = require('should'); +const sinon = require('sinon'); +const common = require('../../../../server/lib/common'); +const manipulator = require('../../../../server/lib/image/manipulator'); +const testUtils = require('../../../utils'); +const sandbox = sinon.sandbox.create(); + +describe('lib/image: manipulator', function () { + afterEach(function () { + sandbox.restore(); + testUtils.unmockNotExistingModule(); + }); + + describe('cases', function () { + let sharp, sharpInstance; + + beforeEach(function () { + sharpInstance = { + metadata: sandbox.stub(), + resize: sandbox.stub(), + rotate: sandbox.stub(), + toFile: sandbox.stub() + }; + + sharp = sandbox.stub().callsFake(() => { + return sharpInstance; + }); + + sharp.cache = sandbox.stub(); + testUtils.mockNotExistingModule('sharp', sharp); + }); + + it('resize image', function () { + sharpInstance.metadata.resolves({width: 2000, height: 2000}); + sharpInstance.toFile.resolves(); + + return manipulator.process({width: 1000}) + .then(() => { + sharp.cache.calledOnce.should.be.true(); + sharpInstance.metadata.calledOnce.should.be.true(); + sharpInstance.toFile.calledOnce.should.be.true(); + sharpInstance.resize.calledOnce.should.be.true(); + sharpInstance.rotate.calledOnce.should.be.true(); + }); + }); + + it('skip resizing if image is too small', function () { + sharpInstance.metadata.resolves({width: 50, height: 50}); + sharpInstance.toFile.resolves(); + + return manipulator.process({width: 1000}) + .then(() => { + sharp.cache.calledOnce.should.be.true(); + sharpInstance.metadata.calledOnce.should.be.true(); + sharpInstance.toFile.calledOnce.should.be.true(); + sharpInstance.resize.calledOnce.should.be.false(); + sharpInstance.rotate.calledOnce.should.be.true(); + }); + }); + + it('sharp throws error during processing', function () { + sharpInstance.metadata.resolves({width: 500, height: 500}); + sharpInstance.toFile.rejects(new Error('whoops')); + + return manipulator.process({width: 2000}) + .then(() => { + '1'.should.eql(1, 'Expected to fail'); + }) + .catch((err) => { + (err instanceof common.errors.InternalServerError).should.be.true; + err.code.should.eql('IMAGE_PROCESSING'); + sharp.cache.calledOnce.should.be.true; + sharpInstance.metadata.calledOnce.should.be.true(); + sharpInstance.toFile.calledOnce.should.be.true(); + sharpInstance.resize.calledOnce.should.be.false(); + sharpInstance.rotate.calledOnce.should.be.true(); + }); + }); + }); + + describe('installation', function () { + beforeEach(function () { + testUtils.mockNotExistingModule('sharp', new Error(), true); + }); + + it('sharp was not installed', function () { + return manipulator.process() + .then(() => { + '1'.should.eql(1, 'Expected to fail'); + }) + .catch((err) => { + (err instanceof common.errors.InternalServerError).should.be.true(); + err.code.should.eql('SHARP_INSTALLATION'); + }); + }); + }); +});