diff --git a/lib/errors.js b/lib/errors.js index a5cb725..f423a44 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -1,5 +1,5 @@ var createError = require('createerror'); module.exports = { - OutputDimensionsExceeded: createError({name: 'OutputDimensionsExceeded'}) + OutputDimensionsExceeded: createError({ name: 'OutputDimensionsExceeded' }) }; diff --git a/lib/getFilterInfosAndTargetContentTypeFromQueryString.js b/lib/getFilterInfosAndTargetContentTypeFromQueryString.js index 8c0d97b..6e07246 100644 --- a/lib/getFilterInfosAndTargetContentTypeFromQueryString.js +++ b/lib/getFilterInfosAndTargetContentTypeFromQueryString.js @@ -8,114 +8,201 @@ var exifReader = require('exif-reader-paras20xx'); var icc = require('icc'); var sharp; var Gifsicle; -var isOperationByEngineNameAndName = {gm: {}}; +var isOperationByEngineNameAndName = { gm: {} }; var filterConstructorByOperationName = {}; var errors = require('./errors'); -['PngQuant', 'PngCrush', 'OptiPng', 'JpegTran', 'Inkscape', 'SvgFilter'].forEach(function (constructorName) { - try { - filterConstructorByOperationName[constructorName.toLowerCase()] = require(constructorName.toLowerCase()); - } catch (e) { - // SvgFilter might fail because of failed contextify installation on windows. - // Dependency chain to contextify: svgfilter --> assetgraph --> jsdom --> contextify - } +[ + 'PngQuant', + 'PngCrush', + 'OptiPng', + 'JpegTran', + 'Inkscape', + 'SvgFilter' +].forEach(function(constructorName) { + try { + filterConstructorByOperationName[ + constructorName.toLowerCase() + ] = require(constructorName.toLowerCase()); + } catch (e) { + // SvgFilter might fail because of failed contextify installation on windows. + // Dependency chain to contextify: svgfilter --> assetgraph --> jsdom --> contextify + } }); -Object.keys(gm.prototype).forEach(function (propertyName) { - if (!/^_|^(?:emit|.*Listeners?|on|once|size|orientation|format|depth|color|res|filesize|identity|write|stream)$/.test(propertyName) && - typeof gm.prototype[propertyName] === 'function') { - isOperationByEngineNameAndName.gm[propertyName] = true; - } +Object.keys(gm.prototype).forEach(function(propertyName) { + if ( + !/^_|^(?:emit|.*Listeners?|on|once|size|orientation|format|depth|color|res|filesize|identity|write|stream)$/.test( + propertyName + ) && + typeof gm.prototype[propertyName] === 'function' + ) { + isOperationByEngineNameAndName.gm[propertyName] = true; + } }); function getMockFileNameForContentType(contentType) { - if (contentType) { - if (contentType === 'image/vnd.microsoft.icon') { - return '.ico'; - } - return '.' + mime._extensions[contentType]; + if (contentType) { + if (contentType === 'image/vnd.microsoft.icon') { + return '.ico'; } + return '.' + mime._extensions[contentType]; + } } // For compatibility with the sharp format switchers (minus webp, which graphicsmagick doesn't support). // Consider adding more from this list: gm convert -list format -['jpeg', 'png'].forEach(function (formatName) { - isOperationByEngineNameAndName.gm[formatName] = true; +['jpeg', 'png'].forEach(function(formatName) { + isOperationByEngineNameAndName.gm[formatName] = true; }); isOperationByEngineNameAndName.gm.extract = true; try { - sharp = require('sharp'); + sharp = require('sharp'); } catch (e) {} try { - Gifsicle = require('gifsicle-stream'); + Gifsicle = require('gifsicle-stream'); } catch (e) {} var sharpFormats = ['png', 'jpeg', 'webp']; if (sharp) { - isOperationByEngineNameAndName.sharp = {}; - ['resize', 'extract', 'sequentialRead', 'crop', 'max', 'background', 'embed', 'flatten', 'rotate', 'flip', 'flop', 'withoutEnlargement', 'ignoreAspectRatio', 'sharpen', 'interpolateWith', 'gamma', 'grayscale', 'greyscale', 'jpeg', 'png', 'webp', 'quality', 'progressive', 'withMetadata', 'compressionLevel', 'setFormat'].forEach(function (sharpOperationName) { - isOperationByEngineNameAndName.sharp[sharpOperationName] = true; - }); + isOperationByEngineNameAndName.sharp = {}; + [ + 'resize', + 'extract', + 'sequentialRead', + 'crop', + 'max', + 'background', + 'embed', + 'flatten', + 'rotate', + 'flip', + 'flop', + 'withoutEnlargement', + 'ignoreAspectRatio', + 'sharpen', + 'interpolateWith', + 'gamma', + 'grayscale', + 'greyscale', + 'jpeg', + 'png', + 'webp', + 'quality', + 'progressive', + 'withMetadata', + 'compressionLevel', + 'setFormat' + ].forEach(function(sharpOperationName) { + isOperationByEngineNameAndName.sharp[sharpOperationName] = true; + }); } var engineNamesByOperationName = {}; -Object.keys(isOperationByEngineNameAndName).forEach(function (engineName) { - Object.keys(isOperationByEngineNameAndName[engineName]).forEach(function (operationName) { - (engineNamesByOperationName[operationName] = engineNamesByOperationName[operationName] || []).push(engineName); - }); +Object.keys(isOperationByEngineNameAndName).forEach(function(engineName) { + Object.keys(isOperationByEngineNameAndName[engineName]).forEach(function( + operationName + ) { + (engineNamesByOperationName[operationName] = + engineNamesByOperationName[operationName] || []).push(engineName); + }); }); function isNumberWithin(num, min, max) { - return typeof num === 'number' && num >= min && num <= max; + return typeof num === 'number' && num >= min && num <= max; } function isValidOperation(name, args) { - var maxDimension = 16384; - switch (name) { + var maxDimension = 16384; + switch (name) { case 'crop': - return args.length === 1 && /^(?:east|west|center|north(?:|west|east)|south(?:|west|east)|attention|entropy)$/.test(args[0]); + return ( + args.length === 1 && + /^(?:east|west|center|north(?:|west|east)|south(?:|west|east)|attention|entropy)$/.test( + args[0] + ) + ); case 'rotate': - return args.length === 0 || (args.length === 1 && (args[0] === 0 || args[0] === 90 || args[0] === 180 || args[0] === 270)); + return ( + args.length === 0 || + (args.length === 1 && + (args[0] === 0 || + args[0] === 90 || + args[0] === 180 || + args[0] === 270)) + ); case 'resize': - if (args.length === 1 || (args.length === 2 && args[1] === '')) { - return isNumberWithin(args[0], 1, maxDimension); - } - if (args.length !== 2) { - return false; - } - if (args[0] === '') { - return isNumberWithin(args[1], 1, maxDimension); - } - return isNumberWithin(args[0], 1, maxDimension) && isNumberWithin(args[1], 1, maxDimension); + if (args.length === 1 || (args.length === 2 && args[1] === '')) { + return isNumberWithin(args[0], 1, maxDimension); + } + if (args.length !== 2) { + return false; + } + if (args[0] === '') { + return isNumberWithin(args[1], 1, maxDimension); + } + return ( + isNumberWithin(args[0], 1, maxDimension) && + isNumberWithin(args[1], 1, maxDimension) + ); case 'extract': - return args.length === 4 && isNumberWithin(args[0], 0, maxDimension - 1) && isNumberWithin(args[1], 0, maxDimension - 1) && isNumberWithin(args[2], 1, maxDimension) && isNumberWithin(args[3], 1, maxDimension); + return ( + args.length === 4 && + isNumberWithin(args[0], 0, maxDimension - 1) && + isNumberWithin(args[1], 0, maxDimension - 1) && + isNumberWithin(args[2], 1, maxDimension) && + isNumberWithin(args[3], 1, maxDimension) + ); case 'interpolateWith': - return args.length === 1 && /^(?:nearest|bilinear|vertexSplitQuadraticBasisSpline|bicubic|locallyBoundedBicubic|nohalo)$/.test(args[0]); + return ( + args.length === 1 && + /^(?:nearest|bilinear|vertexSplitQuadraticBasisSpline|bicubic|locallyBoundedBicubic|nohalo)$/.test( + args[0] + ) + ); case 'background': - return args.length === 1 && /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{9}|[0-9a-f]{12}|[0-9a-f]{4}|[0-9a-f]{8}|[0-9a-f]{6})$/i.test(args[0]); + return ( + args.length === 1 && + /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{9}|[0-9a-f]{12}|[0-9a-f]{4}|[0-9a-f]{8}|[0-9a-f]{6})$/i.test( + args[0] + ) + ); case 'blur': - return args.length === 0 || (args.length === 1 && isNumberWithin(args[0], 0.3, 1000)); + return ( + args.length === 0 || + (args.length === 1 && isNumberWithin(args[0], 0.3, 1000)) + ); case 'sharpen': - return args.length <= 3 && - (typeof args[0] === 'undefined' || typeof args[0] === 'number') && - (typeof args[1] === 'undefined' || typeof args[1] === 'number') && - (typeof args[2] === 'undefined' || typeof args[2] === 'number'); + return ( + args.length <= 3 && + (typeof args[0] === 'undefined' || typeof args[0] === 'number') && + (typeof args[1] === 'undefined' || typeof args[1] === 'number') && + (typeof args[2] === 'undefined' || typeof args[2] === 'number') + ); case 'threshold': - return args.length === 0 || (args.length === 1 && isNumberWithin(args[0], 0, 255)); + return ( + args.length === 0 || + (args.length === 1 && isNumberWithin(args[0], 0, 255)) + ); case 'gamma': - return args.length === 0 || (args.length === 1 && isNumberWithin(args[0], 1, 3)); + return ( + args.length === 0 || + (args.length === 1 && isNumberWithin(args[0], 1, 3)) + ); case 'quality': - return args.length === 1 && isNumberWithin(args[0], 1, 100); + return args.length === 1 && isNumberWithin(args[0], 1, 100); case 'tile': - return args.length <= 2 && - (typeof args[0] === 'undefined' || isNumberWithin(args[0], 1, 8192)) && - (typeof args[1] === 'undefined' || isNumberWithin(args[0], 0, 8192)); + return ( + args.length <= 2 && + (typeof args[0] === 'undefined' || isNumberWithin(args[0], 1, 8192)) && + (typeof args[1] === 'undefined' || isNumberWithin(args[0], 0, 8192)) + ); case 'compressionLevel': - return args.length === 1 && isNumberWithin(args[0], 0, 9); + return args.length === 1 && isNumberWithin(args[0], 0, 9); case 'png': case 'jpeg': case 'gif': @@ -141,16 +228,16 @@ function isValidOperation(name, args) { case 'overshootDeringing': case 'optimizeScans': case 'optimiseScans': - return args.length === 0; + return args.length === 0; // Not supported: overlayWith case 'metadata': - return args.length === 0 || (args.length === 1 && args[0] === true); + return args.length === 0 || (args.length === 1 && args[0] === true); // Engines: case 'sharp': case 'gm': - return args.length === 0; + return args.length === 0; // FIXME: Add validation code for all the below. // https://github.com/papandreou/express-processimage/issues/4 @@ -161,7 +248,7 @@ function isValidOperation(name, args) { case 'optipng': case 'svgfilter': case 'inkscape': - return true; + return true; // Graphicsmagick specific operations: // FIXME: Add validation code for all the below. @@ -358,557 +445,729 @@ function isValidOperation(name, args) { case 'compare': case 'composite': case 'montage': - return true; + return true; default: - return false; - } + return false; + } } -module.exports = function getFilterInfosAndTargetContentTypeFromQueryString(queryString, options) { - options = options || {}; - var filters = options.filters || {}; - var filterInfos = []; - var defaultEngineName = options.defaultEngineName || (sharp && 'sharp') || 'gm'; - var currentEngineName; - var operations = []; - var operationNames = []; - var usedQueryStringFragments = []; - var leftOverQueryStringFragments = []; - var sourceMetadata = options.sourceMetadata || {}; - var targetContentType = sourceMetadata.contentType; - var root = options.root || options.rootPath; - - function checkSharpOrGmOperation(operation) { - if (operation.name === 'resize' && typeof options.maxOutputPixels === 'number' && operation.args.length >= 2 && operation.args[0] * operation.args[1] > options.maxOutputPixels) { - // FIXME: Realizing that we're going over the limit when only one resize operand is given would require knowing the metadata. - // It's a big wtf that the maxOutputPixels option is only enforced some of the time. - throw new errors.OutputDimensionsExceeded('resize: Target dimensions of ' + operation.args[0] + 'x' + operation.args[1] + ' exceed maxOutputPixels (' + options.maxOutputPixels + ')'); - } +module.exports = function getFilterInfosAndTargetContentTypeFromQueryString( + queryString, + options +) { + options = options || {}; + var filters = options.filters || {}; + var filterInfos = []; + var defaultEngineName = + options.defaultEngineName || (sharp && 'sharp') || 'gm'; + var currentEngineName; + var operations = []; + var operationNames = []; + var usedQueryStringFragments = []; + var leftOverQueryStringFragments = []; + var sourceMetadata = options.sourceMetadata || {}; + var targetContentType = sourceMetadata.contentType; + var root = options.root || options.rootPath; + + function checkSharpOrGmOperation(operation) { + if ( + operation.name === 'resize' && + typeof options.maxOutputPixels === 'number' && + operation.args.length >= 2 && + operation.args[0] * operation.args[1] > options.maxOutputPixels + ) { + // FIXME: Realizing that we're going over the limit when only one resize operand is given would require knowing the metadata. + // It's a big wtf that the maxOutputPixels option is only enforced some of the time. + throw new errors.OutputDimensionsExceeded( + 'resize: Target dimensions of ' + + operation.args[0] + + 'x' + + operation.args[1] + + ' exceed maxOutputPixels (' + + options.maxOutputPixels + + ')' + ); } + } - function flushOperations() { - if (operations.length > 0) { - var engineName = currentEngineName; - var operationIndex = operationNames.length; - operationNames.push('sharpOrGm'); - filterInfos.push({ - operationName: 'sharpOrGm', - operations: operations, - usedQueryStringFragments: operations.map(function (operation) { - return operation.usedQueryStringFragment; - }), - create: function () { - var sourceContentType = (this.operations[0] && this.operations[0].sourceContentType) || sourceMetadata.contentType; - if (sourceContentType === 'image/gif' && !this.operations.some(function (operation) { - return operation.name === 'setFormat' && sharpFormats.indexOf(operation.args[0]) > -1; - })) { - engineName = 'gm'; - // Gotcha: gifsicle does not support --resize-fit in a way where the image will be enlarged - // to fit the bounding box, so &withoutEnlargement is assumed, but not required: - // Raised the issue here: https://github.com/kohler/gifsicle/issues/67 - if (filters.gifsicle !== false && Gifsicle && this.operations.every(function (operation) { - return operation.name === 'resize' || operation.name === 'extract' || operation.name === 'rotate' || operation.name === 'withoutEnlargement' || operation.name === 'progressive' || operation.name === 'crop' || operation.name === 'ignoreAspectRatio' || (operation.name === 'setFormat' && operation.args[0] === 'gif'); - })) { - engineName = 'gifsicle'; - } - } + function flushOperations() { + if (operations.length > 0) { + var engineName = currentEngineName; + var operationIndex = operationNames.length; + operationNames.push('sharpOrGm'); + filterInfos.push({ + operationName: 'sharpOrGm', + operations: operations, + usedQueryStringFragments: operations.map(function(operation) { + return operation.usedQueryStringFragment; + }), + create: function() { + var sourceContentType = + (this.operations[0] && this.operations[0].sourceContentType) || + sourceMetadata.contentType; + if ( + sourceContentType === 'image/gif' && + !this.operations.some(function(operation) { + return ( + operation.name === 'setFormat' && + sharpFormats.indexOf(operation.args[0]) > -1 + ); + }) + ) { + engineName = 'gm'; + // Gotcha: gifsicle does not support --resize-fit in a way where the image will be enlarged + // to fit the bounding box, so &withoutEnlargement is assumed, but not required: + // Raised the issue here: https://github.com/kohler/gifsicle/issues/67 + if ( + filters.gifsicle !== false && + Gifsicle && + this.operations.every(function(operation) { + return ( + operation.name === 'resize' || + operation.name === 'extract' || + operation.name === 'rotate' || + operation.name === 'withoutEnlargement' || + operation.name === 'progressive' || + operation.name === 'crop' || + operation.name === 'ignoreAspectRatio' || + (operation.name === 'setFormat' && + operation.args[0] === 'gif') + ); + }) + ) { + engineName = 'gifsicle'; + } + } - this.targetContentType = this.outputContentType || targetContentType || sourceContentType; - - var operations = this.operations; - this.operationName = engineName; - operationNames[operationIndex] = engineName; - if (engineName === 'gifsicle') { - var gifsicleArgs = []; - var seenOperationThatMustComeBeforeExtract = false; - var gifsicles = []; - var flush = function () { - if (gifsicleArgs.length > 0) { - gifsicles.push(new Gifsicle(gifsicleArgs)); - seenOperationThatMustComeBeforeExtract = false; - gifsicleArgs = []; - } - }; - - operations.forEach(function (operation) { - if (operation.name === 'resize') { - if (operation.args[0] === undefined) { - gifsicleArgs.push('--resize-height', operation.args[1]); - } else if (operation.args[1] === undefined) { - gifsicleArgs.push('--resize-width', operation.args[0]); - } else { - if (operations.some(function (operation) { return operation.name === 'ignoreAspectRatio'; })) { - gifsicleArgs.push('--resize', operation.args[0] + 'x' + operation.args[1]); - } else { - gifsicleArgs.push('--resize-fit', operation.args[0] + 'x' + operation.args[1]); - } - } - seenOperationThatMustComeBeforeExtract = true; - } else if (operation.name === 'extract') { - if (seenOperationThatMustComeBeforeExtract) { - flush(); - } - gifsicleArgs.push('--crop', operation.args[0] + ',' + operation.args[1] + '+' + operation.args[2] + 'x' + operation.args[3]); - } else if (operation.name === 'rotate' && /^(?:90|180|270)$/.test(operation.args[0])) { - gifsicleArgs.push('--rotate-' + operation.args[0]); - seenOperationThatMustComeBeforeExtract = true; - } else if (operation.name === 'progressive') { - gifsicleArgs.push('--interlace'); - } - }); - flush(); - return gifsicles.length === 1 ? gifsicles[0] : gifsicles; - } else if (engineName === 'sharp') { - var sharpOperationsForThisInstance = [].concat(operations); - if (options.maxInputPixels) { - sharpOperationsForThisInstance.unshift({name: 'limitInputPixels', args: [options.maxInputPixels]}); - } - var sharpInstance = sharp(); - if (operations.some(function (operation) { return operation.name === 'resize'; })) { - sharpInstance.max(); - } + this.targetContentType = + this.outputContentType || targetContentType || sourceContentType; - // Sharp has deprecated the use of progressive() and quality() in favor of - // passing those options to an explicit conversion, eg. .jpeg({quality: ..., progressive: true}) - var converterOptions; - var converterOperation; - for (var i = 0 ; i < sharpOperationsForThisInstance.length ; i += 1) { - var operation = sharpOperationsForThisInstance[i]; - if (operation.name === 'progressive' || operation.name === 'quality') { - var value = true; - if (operation.args && operation.args[0]) { - value = operation.args[0]; - } - converterOptions = converterOptions || {}; - converterOptions[operation.name] = value; - sharpOperationsForThisInstance.splice(i, 1); - i -= 1; - } else if (sharpFormats.indexOf(operation.name) !== -1) { - converterOperation = operation; - } - } - if (converterOptions) { - if (converterOperation) { - converterOperation.args = converterOperation.args || []; - converterOperation.args[0] = converterOperation.args[0] || {}; - _.extend(converterOperation.args[0], converterOptions); - } else { - sharpOperationsForThisInstance.push({name: this.targetContentType.replace(/^image\//, ''), args: [converterOptions]}); - } - } + var operations = this.operations; + this.operationName = engineName; + operationNames[operationIndex] = engineName; + if (engineName === 'gifsicle') { + var gifsicleArgs = []; + var seenOperationThatMustComeBeforeExtract = false; + var gifsicles = []; + var flush = function() { + if (gifsicleArgs.length > 0) { + gifsicles.push(new Gifsicle(gifsicleArgs)); + seenOperationThatMustComeBeforeExtract = false; + gifsicleArgs = []; + } + }; - sharpOperationsForThisInstance.forEach(function (operation) { - checkSharpOrGmOperation(operation); - var args = operation.args; - // Support setFormat operation - if (operation.name === 'setFormat' && args.length === 1) { - operation.name = args[0]; // use the argument as the target format - args = []; - } - // Compensate for https://github.com/lovell/sharp/issues/276 - if (operation.name === 'extract' && args.length >= 4) { - args = [ { left: args[0], top: args[1], width: args[2], height: args[3] } ]; - } - sharpInstance[operation.name].apply(sharpInstance, args); - }); - return sharpInstance; - } else if (engineName === 'gm') { - var gmOperationsForThisInstance = [].concat(operations); - // For some reason the gm module doesn't expose itself as a readable/writable stream, - // so we need to wrap it into one: - - var readStream = new Stream(); - readStream.readable = true; - - var readWriteStream = new Stream(); - readWriteStream.readable = readWriteStream.writable = true; - var spawned = false; - readWriteStream.write = function (chunk) { - if (!spawned) { - spawned = true; - var seenData = false; - var hasEnded = false; - var gmInstance = gm(readStream, getMockFileNameForContentType(gmOperationsForThisInstance[0].sourceContentType)); - if (options.maxInputPixels) { - gmInstance.limit('pixels', options.maxInputPixels); - } - var resize; - var crop; - var withoutEnlargement; - var ignoreAspectRatio; - for (var i = 0 ; i < gmOperationsForThisInstance.length ; i += 1) { - var gmOperation = gmOperationsForThisInstance[i]; - if (gmOperation.name === 'resize') { - resize = gmOperation; - } else if (gmOperation.name === 'crop') { - crop = gmOperation; - } else if (gmOperation.name === 'withoutEnlargement') { - withoutEnlargement = gmOperation; - } else if (gmOperation.name === 'ignoreAspectRatio') { - ignoreAspectRatio = gmOperation; - } - } - if (resize) { - var flags = ''; - if (withoutEnlargement) { - flags += '>'; - } - if (ignoreAspectRatio) { - flags += '!'; - } - if (crop) { - gmOperationsForThisInstance.push({ - name: 'extent', - args: [].concat(resize.args) - }); - flags += '^'; - } - if (flags.length > 0) { - resize.args.push(flags); - } - } - gmOperationsForThisInstance.reduce(function (gmInstance, gmOperation) { - checkSharpOrGmOperation(gmOperation); - if (gmOperation.name === 'rotate' && gmOperation.args.length === 1) { - gmOperation = _.extend({}, gmOperation); - gmOperation.args = ['transparent', gmOperation.args[0]]; - } - if (gmOperation.name === 'extract') { - gmOperation.name = 'crop'; - gmOperation.args = [gmOperation.args[2], gmOperation.args[3], gmOperation.args[0], gmOperation.args[1]]; - } else if (gmOperation.name === 'crop') { - gmOperation.name = 'gravity'; - gmOperation.args = [ - { - northwest: 'NorthWest', - north: 'North', - northeast: 'NorthEast', - west: 'West', - center: 'Center', - east: 'East', - southwest: 'SouthWest', - south: 'South', - southeast: 'SouthEast' - }[String(gmOperation.args[0]).toLowerCase()] || 'Center' - ]; - } - if (gmOperation.name === 'progressive') { - gmOperation.name = 'interlace'; - gmOperation.args = [ 'line' ]; - } - if (typeof gmInstance[gmOperation.name] === 'function') { - if (gmOperation.name === 'resize' && gmOperation.args[1] === undefined) { - // gm 1.3.18 does not support `-resize 500x` so make sure we omit the x: - return gmInstance.out('-resize', gmOperation.args[0] + (gmOperation[2] || '')); - } else { - return gmInstance[gmOperation.name].apply(gmInstance, gmOperation.args); - } - } else { - return gmInstance; - } - }, gmInstance).stream(function (err, stdout, stderr) { - if (err) { - hasEnded = true; - return readWriteStream.emit('error', err); - } - stdout.on('data', function (chunk) { - seenData = true; - readWriteStream.emit('data', chunk); - }).once('end', function () { - if (!hasEnded) { - if (seenData) { - readWriteStream.emit('end'); - } else { - readWriteStream.emit('error', new Error('The gm stream ended without emitting any data')); - } - hasEnded = true; - } - }); - }); - } - readStream.emit('data', chunk); - }; - readWriteStream.end = function (chunk) { - if (chunk) { - readWriteStream.write(chunk); - } - readStream.emit('end'); - }; - return readWriteStream; - } else { - throw new Error('Internal error'); - } + operations.forEach(function(operation) { + if (operation.name === 'resize') { + if (operation.args[0] === undefined) { + gifsicleArgs.push('--resize-height', operation.args[1]); + } else if (operation.args[1] === undefined) { + gifsicleArgs.push('--resize-width', operation.args[0]); + } else { + if ( + operations.some(function(operation) { + return operation.name === 'ignoreAspectRatio'; + }) + ) { + gifsicleArgs.push( + '--resize', + operation.args[0] + 'x' + operation.args[1] + ); + } else { + gifsicleArgs.push( + '--resize-fit', + operation.args[0] + 'x' + operation.args[1] + ); + } } + seenOperationThatMustComeBeforeExtract = true; + } else if (operation.name === 'extract') { + if (seenOperationThatMustComeBeforeExtract) { + flush(); + } + gifsicleArgs.push( + '--crop', + operation.args[0] + + ',' + + operation.args[1] + + '+' + + operation.args[2] + + 'x' + + operation.args[3] + ); + } else if ( + operation.name === 'rotate' && + /^(?:90|180|270)$/.test(operation.args[0]) + ) { + gifsicleArgs.push('--rotate-' + operation.args[0]); + seenOperationThatMustComeBeforeExtract = true; + } else if (operation.name === 'progressive') { + gifsicleArgs.push('--interlace'); + } }); - operations = []; - } - currentEngineName = undefined; - } + flush(); + return gifsicles.length === 1 ? gifsicles[0] : gifsicles; + } else if (engineName === 'sharp') { + var sharpOperationsForThisInstance = [].concat(operations); + if (options.maxInputPixels) { + sharpOperationsForThisInstance.unshift({ + name: 'limitInputPixels', + args: [options.maxInputPixels] + }); + } + var sharpInstance = sharp(); + if ( + operations.some(function(operation) { + return operation.name === 'resize'; + }) + ) { + sharpInstance.max(); + } - var keyValuePairs = queryString.split('&'); - keyValuePairs.forEach(function (keyValuePair) { - var matchKeyValuePair = keyValuePair.match(/^([^=]+)(?:=(.*))?/); - if (matchKeyValuePair) { - var operationName = decodeURIComponent(matchKeyValuePair[1]); - // Split by non-URL encoded comma or plus: - var operationArgs = matchKeyValuePair[2] ? matchKeyValuePair[2].split(/[+,]/).map(function (arg) { - arg = decodeURIComponent(arg); - if (/^\d+$/.test(arg)) { - return parseInt(arg, 10); - } else if (arg === 'true') { - return true; - } else if (arg === 'false') { - return false; - } else { - return arg; + // Sharp has deprecated the use of progressive() and quality() in favor of + // passing those options to an explicit conversion, eg. .jpeg({quality: ..., progressive: true}) + var converterOptions; + var converterOperation; + for (var i = 0; i < sharpOperationsForThisInstance.length; i += 1) { + var operation = sharpOperationsForThisInstance[i]; + if ( + operation.name === 'progressive' || + operation.name === 'quality' + ) { + var value = true; + if (operation.args && operation.args[0]) { + value = operation.args[0]; } - }) : []; + converterOptions = converterOptions || {}; + converterOptions[operation.name] = value; + sharpOperationsForThisInstance.splice(i, 1); + i -= 1; + } else if (sharpFormats.indexOf(operation.name) !== -1) { + converterOperation = operation; + } + } + if (converterOptions) { + if (converterOperation) { + converterOperation.args = converterOperation.args || []; + converterOperation.args[0] = converterOperation.args[0] || {}; + _.extend(converterOperation.args[0], converterOptions); + } else { + sharpOperationsForThisInstance.push({ + name: this.targetContentType.replace(/^image\//, ''), + args: [converterOptions] + }); + } + } - if (!isValidOperation(operationName, operationArgs) || (typeof options.allowOperation === 'function' && !options.allowOperation(operationName, operationArgs))) { - leftOverQueryStringFragments.push(keyValuePair); - } else { - if (operationName === 'resize') { - if (typeof options.maxOutputPixels === 'number') { - if (operationArgs[0] === '') { - operationArgs[0] = Math.floor(options.maxOutputPixels / operationArgs[1]); - } else if (operationArgs[1] === '') { - operationArgs[1] = Math.floor(options.maxOutputPixels / operationArgs[0]); - } - } else { - operationArgs = operationArgs.map(function (arg) { - return arg === '' ? undefined : arg; - }); - } - } + sharpOperationsForThisInstance.forEach(function(operation) { + checkSharpOrGmOperation(operation); + var args = operation.args; + // Support setFormat operation + if (operation.name === 'setFormat' && args.length === 1) { + operation.name = args[0]; // use the argument as the target format + args = []; + } + // Compensate for https://github.com/lovell/sharp/issues/276 + if (operation.name === 'extract' && args.length >= 4) { + args = [ + { + left: args[0], + top: args[1], + width: args[2], + height: args[3] + } + ]; + } + sharpInstance[operation.name].apply(sharpInstance, args); + }); + return sharpInstance; + } else if (engineName === 'gm') { + var gmOperationsForThisInstance = [].concat(operations); + // For some reason the gm module doesn't expose itself as a readable/writable stream, + // so we need to wrap it into one: + + var readStream = new Stream(); + readStream.readable = true; - var filterInfo; - if (filters[operationName]) { - flushOperations(); - filterInfo = filters[operationName](operationArgs, { - numPreceedingFilters: filterInfos.length + var readWriteStream = new Stream(); + readWriteStream.readable = readWriteStream.writable = true; + var spawned = false; + readWriteStream.write = function(chunk) { + if (!spawned) { + spawned = true; + var seenData = false; + var hasEnded = false; + var gmInstance = gm( + readStream, + getMockFileNameForContentType( + gmOperationsForThisInstance[0].sourceContentType + ) + ); + if (options.maxInputPixels) { + gmInstance.limit('pixels', options.maxInputPixels); + } + var resize; + var crop; + var withoutEnlargement; + var ignoreAspectRatio; + for ( + var i = 0; + i < gmOperationsForThisInstance.length; + i += 1 + ) { + var gmOperation = gmOperationsForThisInstance[i]; + if (gmOperation.name === 'resize') { + resize = gmOperation; + } else if (gmOperation.name === 'crop') { + crop = gmOperation; + } else if (gmOperation.name === 'withoutEnlargement') { + withoutEnlargement = gmOperation; + } else if (gmOperation.name === 'ignoreAspectRatio') { + ignoreAspectRatio = gmOperation; + } + } + if (resize) { + var flags = ''; + if (withoutEnlargement) { + flags += '>'; + } + if (ignoreAspectRatio) { + flags += '!'; + } + if (crop) { + gmOperationsForThisInstance.push({ + name: 'extent', + args: [].concat(resize.args) }); - if (filterInfo) { - filterInfo.usedQueryStringFragments = [keyValuePair]; - filterInfo.operationName = operationName; - if (filterInfo.outputContentType) { - targetContentType = filterInfo.outputContentType; - } - filterInfos.push(filterInfo); - operationNames.push(operationName); - usedQueryStringFragments.push(keyValuePair); + flags += '^'; + } + if (flags.length > 0) { + resize.args.push(flags); + } + } + gmOperationsForThisInstance + .reduce(function(gmInstance, gmOperation) { + checkSharpOrGmOperation(gmOperation); + if ( + gmOperation.name === 'rotate' && + gmOperation.args.length === 1 + ) { + gmOperation = _.extend({}, gmOperation); + gmOperation.args = ['transparent', gmOperation.args[0]]; + } + if (gmOperation.name === 'extract') { + gmOperation.name = 'crop'; + gmOperation.args = [ + gmOperation.args[2], + gmOperation.args[3], + gmOperation.args[0], + gmOperation.args[1] + ]; + } else if (gmOperation.name === 'crop') { + gmOperation.name = 'gravity'; + gmOperation.args = [ + { + northwest: 'NorthWest', + north: 'North', + northeast: 'NorthEast', + west: 'West', + center: 'Center', + east: 'East', + southwest: 'SouthWest', + south: 'South', + southeast: 'SouthEast' + }[String(gmOperation.args[0]).toLowerCase()] || 'Center' + ]; + } + if (gmOperation.name === 'progressive') { + gmOperation.name = 'interlace'; + gmOperation.args = ['line']; + } + if (typeof gmInstance[gmOperation.name] === 'function') { + if ( + gmOperation.name === 'resize' && + gmOperation.args[1] === undefined + ) { + // gm 1.3.18 does not support `-resize 500x` so make sure we omit the x: + return gmInstance.out( + '-resize', + gmOperation.args[0] + (gmOperation[2] || '') + ); + } else { + return gmInstance[gmOperation.name].apply( + gmInstance, + gmOperation.args + ); + } } else { - leftOverQueryStringFragments.push(keyValuePair); + return gmInstance; } - } else if (operationName === 'metadata' && sharp) { - flushOperations(); - filterInfos.push({ - metadata: true, - sourceContentType: targetContentType || sourceMetadata.contentType, - outputContentType: targetContentType, - create: function () { - var sourceContentType = this.sourceContentType; - var sharpInstance = sharp(); - var duplexStream = new Stream.Duplex(); - var animatedGifDetector; - var isAnimated; - if (sourceContentType === 'image/gif') { - animatedGifDetector = createAnimatedGifDetector(); - animatedGifDetector.once('animated', function () { - isAnimated = true; - this.emit('decided'); - animatedGifDetector = null; - }); - - duplexStream.once('finish', function () { - if (typeof isAnimated === 'undefined') { - isAnimated = false; - if (animatedGifDetector) { - animatedGifDetector.emit('decided', false); - animatedGifDetector = null; - } - } - }); - } - duplexStream._write = function (chunk, encoding, cb) { - if (animatedGifDetector) { - animatedGifDetector.write(chunk); - } - if (sharpInstance && sharpInstance.write(chunk, encoding) === false && !animatedGifDetector) { - sharpInstance.once('drain', cb); - } else { - cb(); - } - }; - // Make sure that we do not call sharpInstance.metadata multiple times: - var metadataCalled = false; - duplexStream._read = function (size) { - if (metadataCalled) { - return; - } - metadataCalled = true; - // Caveat: sharp's metadata will buffer the entire compressed image before - // calling the callback :/ - // https://github.com/lovell/sharp/issues/236 - sharpInstance.metadata(function (err, metadata) { - sharpInstance = null; - if (err) { - metadata = _.defaults({ error: err.message }, sourceMetadata); - } else { - if (metadata.format === 'magick') { - // https://github.com/lovell/sharp/issues/377 - metadata.contentType = sourceContentType; - metadata.format = sourceContentType && sourceContentType.replace(/^image\//, ''); - } else if (metadata.format) { - // metadata.format is one of 'jpeg', 'png', 'webp' so this should be safe: - metadata.contentType = 'image/' + metadata.format; - } - _.defaults(metadata, sourceMetadata); - if (metadata.exif) { - var exifData; - try { - exifData = exifReader(metadata.exif); - } catch (e) { - // Error: Invalid EXIF - } - metadata.exif = undefined; - if (exifData) { - const orientation = exifData.image && exifData.image.Orientation; - // Check if the image.Orientation EXIF tag specifies says that the - // width and height are to be flipped - // http://sylvana.net/jpegcrop/exif_orientation.html - if (typeof orientation === 'number' && orientation >= 5 && orientation <= 8) { - metadata.orientedWidth = metadata.height; - metadata.orientedHeight = metadata.width; - } else { - metadata.orientedWidth = metadata.width; - metadata.orientedHeight = metadata.height; - } - _.defaults(metadata, exifData); - } - } - if (metadata.icc) { - try { - metadata.icc = icc.parse(metadata.icc); - } catch (e) { - // Error: Error: Invalid ICC profile, remove the Buffer - metadata.icc = undefined; - } - } - if (metadata.format === 'magick') { - metadata.contentType = targetContentType; - } - } - function proceed() { - duplexStream.push(JSON.stringify(metadata)); - duplexStream.push(null); - } - if (typeof isAnimated === 'boolean') { - metadata.animated = isAnimated; - proceed(); - } else if (animatedGifDetector) { - animatedGifDetector.once('decided', function (isAnimated) { - metadata.animated = isAnimated; - proceed(); - }); - } else { - proceed(); - } - }); - }; - duplexStream.once('finish', function () { - if (sharpInstance) { - sharpInstance.end(); - } - }); - return duplexStream; - } - }); - targetContentType = 'application/json; charset=utf-8'; - usedQueryStringFragments.push(keyValuePair); - } else if (isOperationByEngineNameAndName[operationName]) { - usedQueryStringFragments.push(keyValuePair); - flushOperations(); - defaultEngineName = operationName; - } else if (engineNamesByOperationName[operationName]) { - // Check if at least one of the engines supporting this operation is allowed - var candidateEngineNames = engineNamesByOperationName[operationName].filter(function (engineName) { - return filters[engineName] !== false; - }); - if (candidateEngineNames.length > 0) { - if (currentEngineName && !isOperationByEngineNameAndName[currentEngineName]) { - flushOperations(); + }, gmInstance) + .stream(function(err, stdout, stderr) { + if (err) { + hasEnded = true; + return readWriteStream.emit('error', err); + } + stdout + .on('data', function(chunk) { + seenData = true; + readWriteStream.emit('data', chunk); + }) + .once('end', function() { + if (!hasEnded) { + if (seenData) { + readWriteStream.emit('end'); + } else { + readWriteStream.emit( + 'error', + new Error( + 'The gm stream ended without emitting any data' + ) + ); + } + hasEnded = true; } + }); + }); + } + readStream.emit('data', chunk); + }; + readWriteStream.end = function(chunk) { + if (chunk) { + readWriteStream.write(chunk); + } + readStream.emit('end'); + }; + return readWriteStream; + } else { + throw new Error('Internal error'); + } + } + }); + operations = []; + } + currentEngineName = undefined; + } - if (!currentEngineName || candidateEngineNames.indexOf(currentEngineName) === -1) { - if (candidateEngineNames.indexOf(defaultEngineName) !== -1) { - currentEngineName = defaultEngineName; - } else { - currentEngineName = candidateEngineNames[0]; - } - } - var sourceContentType = targetContentType; - var targetFormat; - if (operationName === 'setFormat' && operationArgs.length > 0) { - targetFormat = operationArgs[0].toLowerCase(); - if (targetFormat === 'jpg') { - targetFormat = 'jpeg'; - } - } else if (operationName === 'jpeg' || operationName === 'png' || operationName === 'webp') { - targetFormat = operationName; - operationName = 'setFormat'; - } - if (targetFormat) { - operationArgs = [targetFormat]; - targetContentType = 'image/' + targetFormat; - // fallback to another engine if the requested format is not supported by sharp - if (currentEngineName === 'sharp' && sharpFormats.indexOf(targetFormat) === -1) { - currentEngineName = 'gm'; - } - } - operations.push({sourceContentType: sourceContentType, name: operationName, args: operationArgs, usedQueryStringFragment: keyValuePair}); - usedQueryStringFragments.push(keyValuePair); + var keyValuePairs = queryString.split('&'); + keyValuePairs.forEach(function(keyValuePair) { + var matchKeyValuePair = keyValuePair.match(/^([^=]+)(?:=(.*))?/); + if (matchKeyValuePair) { + var operationName = decodeURIComponent(matchKeyValuePair[1]); + // Split by non-URL encoded comma or plus: + var operationArgs = matchKeyValuePair[2] + ? matchKeyValuePair[2].split(/[+,]/).map(function(arg) { + arg = decodeURIComponent(arg); + if (/^\d+$/.test(arg)) { + return parseInt(arg, 10); + } else if (arg === 'true') { + return true; + } else if (arg === 'false') { + return false; + } else { + return arg; + } + }) + : []; + + if ( + !isValidOperation(operationName, operationArgs) || + (typeof options.allowOperation === 'function' && + !options.allowOperation(operationName, operationArgs)) + ) { + leftOverQueryStringFragments.push(keyValuePair); + } else { + if (operationName === 'resize') { + if (typeof options.maxOutputPixels === 'number') { + if (operationArgs[0] === '') { + operationArgs[0] = Math.floor( + options.maxOutputPixels / operationArgs[1] + ); + } else if (operationArgs[1] === '') { + operationArgs[1] = Math.floor( + options.maxOutputPixels / operationArgs[0] + ); + } + } else { + operationArgs = operationArgs.map(function(arg) { + return arg === '' ? undefined : arg; + }); + } + } + + var filterInfo; + if (filters[operationName]) { + flushOperations(); + filterInfo = filters[operationName](operationArgs, { + numPreceedingFilters: filterInfos.length + }); + if (filterInfo) { + filterInfo.usedQueryStringFragments = [keyValuePair]; + filterInfo.operationName = operationName; + if (filterInfo.outputContentType) { + targetContentType = filterInfo.outputContentType; + } + filterInfos.push(filterInfo); + operationNames.push(operationName); + usedQueryStringFragments.push(keyValuePair); + } else { + leftOverQueryStringFragments.push(keyValuePair); + } + } else if (operationName === 'metadata' && sharp) { + flushOperations(); + filterInfos.push({ + metadata: true, + sourceContentType: targetContentType || sourceMetadata.contentType, + outputContentType: targetContentType, + create: function() { + var sourceContentType = this.sourceContentType; + var sharpInstance = sharp(); + var duplexStream = new Stream.Duplex(); + var animatedGifDetector; + var isAnimated; + if (sourceContentType === 'image/gif') { + animatedGifDetector = createAnimatedGifDetector(); + animatedGifDetector.once('animated', function() { + isAnimated = true; + this.emit('decided'); + animatedGifDetector = null; + }); + + duplexStream.once('finish', function() { + if (typeof isAnimated === 'undefined') { + isAnimated = false; + if (animatedGifDetector) { + animatedGifDetector.emit('decided', false); + animatedGifDetector = null; } + } + }); + } + duplexStream._write = function(chunk, encoding, cb) { + if (animatedGifDetector) { + animatedGifDetector.write(chunk); + } + if ( + sharpInstance && + sharpInstance.write(chunk, encoding) === false && + !animatedGifDetector + ) { + sharpInstance.once('drain', cb); } else { - var operationNameLowerCase = operationName.toLowerCase(), - FilterConstructor = filterConstructorByOperationName[operationNameLowerCase]; - if (FilterConstructor && filters[operationNameLowerCase] !== false) { - operationNames.push(operationNameLowerCase); - flushOperations(); - if (operationNameLowerCase === 'svgfilter' && root && options.sourceFilePath) { - operationArgs.push('--root', 'file://' + root, '--url', 'file://' + options.sourceFilePath); - } - filterInfo = { - create: function () { - return new FilterConstructor(operationArgs); - }, - operationName: operationNameLowerCase, - usedQueryStringFragments: [keyValuePair] - }; - filterInfos.push(filterInfo); - usedQueryStringFragments.push(keyValuePair); - if (operationNameLowerCase === 'inkscape') { - var filter = filterInfo.create(); - filterInfo.create = function () { - return filter; - }; - targetContentType = 'image/' + filter.outputFormat; + cb(); + } + }; + // Make sure that we do not call sharpInstance.metadata multiple times: + var metadataCalled = false; + duplexStream._read = function(size) { + if (metadataCalled) { + return; + } + metadataCalled = true; + // Caveat: sharp's metadata will buffer the entire compressed image before + // calling the callback :/ + // https://github.com/lovell/sharp/issues/236 + sharpInstance.metadata(function(err, metadata) { + sharpInstance = null; + if (err) { + metadata = _.defaults( + { error: err.message }, + sourceMetadata + ); + } else { + if (metadata.format === 'magick') { + // https://github.com/lovell/sharp/issues/377 + metadata.contentType = sourceContentType; + metadata.format = + sourceContentType && + sourceContentType.replace(/^image\//, ''); + } else if (metadata.format) { + // metadata.format is one of 'jpeg', 'png', 'webp' so this should be safe: + metadata.contentType = 'image/' + metadata.format; + } + _.defaults(metadata, sourceMetadata); + if (metadata.exif) { + var exifData; + try { + exifData = exifReader(metadata.exif); + } catch (e) { + // Error: Invalid EXIF + } + metadata.exif = undefined; + if (exifData) { + const orientation = + exifData.image && exifData.image.Orientation; + // Check if the image.Orientation EXIF tag specifies says that the + // width and height are to be flipped + // http://sylvana.net/jpegcrop/exif_orientation.html + if ( + typeof orientation === 'number' && + orientation >= 5 && + orientation <= 8 + ) { + metadata.orientedWidth = metadata.height; + metadata.orientedHeight = metadata.width; + } else { + metadata.orientedWidth = metadata.width; + metadata.orientedHeight = metadata.height; } - } else { - leftOverQueryStringFragments.push(keyValuePair); + _.defaults(metadata, exifData); + } + } + if (metadata.icc) { + try { + metadata.icc = icc.parse(metadata.icc); + } catch (e) { + // Error: Error: Invalid ICC profile, remove the Buffer + metadata.icc = undefined; + } + } + if (metadata.format === 'magick') { + metadata.contentType = targetContentType; } + } + function proceed() { + duplexStream.push(JSON.stringify(metadata)); + duplexStream.push(null); + } + if (typeof isAnimated === 'boolean') { + metadata.animated = isAnimated; + proceed(); + } else if (animatedGifDetector) { + animatedGifDetector.once('decided', function(isAnimated) { + metadata.animated = isAnimated; + proceed(); + }); + } else { + proceed(); + } + }); + }; + duplexStream.once('finish', function() { + if (sharpInstance) { + sharpInstance.end(); } + }); + return duplexStream; + } + }); + targetContentType = 'application/json; charset=utf-8'; + usedQueryStringFragments.push(keyValuePair); + } else if (isOperationByEngineNameAndName[operationName]) { + usedQueryStringFragments.push(keyValuePair); + flushOperations(); + defaultEngineName = operationName; + } else if (engineNamesByOperationName[operationName]) { + // Check if at least one of the engines supporting this operation is allowed + var candidateEngineNames = engineNamesByOperationName[ + operationName + ].filter(function(engineName) { + return filters[engineName] !== false; + }); + if (candidateEngineNames.length > 0) { + if ( + currentEngineName && + !isOperationByEngineNameAndName[currentEngineName] + ) { + flushOperations(); } + + if ( + !currentEngineName || + candidateEngineNames.indexOf(currentEngineName) === -1 + ) { + if (candidateEngineNames.indexOf(defaultEngineName) !== -1) { + currentEngineName = defaultEngineName; + } else { + currentEngineName = candidateEngineNames[0]; + } + } + var sourceContentType = targetContentType; + var targetFormat; + if (operationName === 'setFormat' && operationArgs.length > 0) { + targetFormat = operationArgs[0].toLowerCase(); + if (targetFormat === 'jpg') { + targetFormat = 'jpeg'; + } + } else if ( + operationName === 'jpeg' || + operationName === 'png' || + operationName === 'webp' + ) { + targetFormat = operationName; + operationName = 'setFormat'; + } + if (targetFormat) { + operationArgs = [targetFormat]; + targetContentType = 'image/' + targetFormat; + // fallback to another engine if the requested format is not supported by sharp + if ( + currentEngineName === 'sharp' && + sharpFormats.indexOf(targetFormat) === -1 + ) { + currentEngineName = 'gm'; + } + } + operations.push({ + sourceContentType: sourceContentType, + name: operationName, + args: operationArgs, + usedQueryStringFragment: keyValuePair + }); + usedQueryStringFragments.push(keyValuePair); + } + } else { + var operationNameLowerCase = operationName.toLowerCase(); + + var FilterConstructor = + filterConstructorByOperationName[operationNameLowerCase]; + if (FilterConstructor && filters[operationNameLowerCase] !== false) { + operationNames.push(operationNameLowerCase); + flushOperations(); + if ( + operationNameLowerCase === 'svgfilter' && + root && + options.sourceFilePath + ) { + operationArgs.push( + '--root', + 'file://' + root, + '--url', + 'file://' + options.sourceFilePath + ); + } + filterInfo = { + create: function() { + return new FilterConstructor(operationArgs); + }, + operationName: operationNameLowerCase, + usedQueryStringFragments: [keyValuePair] + }; + filterInfos.push(filterInfo); + usedQueryStringFragments.push(keyValuePair); + if (operationNameLowerCase === 'inkscape') { + var filter = filterInfo.create(); + filterInfo.create = function() { + return filter; + }; + targetContentType = 'image/' + filter.outputFormat; + } + } else { + leftOverQueryStringFragments.push(keyValuePair); + } } - }); - flushOperations(); - - return { - targetContentType: targetContentType, - operationNames: operationNames, - filterInfos: filterInfos, - usedQueryStringFragments: usedQueryStringFragments, - leftOverQueryStringFragments: leftOverQueryStringFragments - }; + } + } + }); + flushOperations(); + + return { + targetContentType: targetContentType, + operationNames: operationNames, + filterInfos: filterInfos, + usedQueryStringFragments: usedQueryStringFragments, + leftOverQueryStringFragments: leftOverQueryStringFragments + }; }; module.exports.sharp = sharp; diff --git a/lib/processImage.js b/lib/processImage.js index 9061c01..07f6b72 100644 --- a/lib/processImage.js +++ b/lib/processImage.js @@ -1,265 +1,355 @@ -var Path = require('path'), - _ = require('underscore'), - httpErrors = require('httperrors'), - getFilterInfosAndTargetContentTypeFromQueryString = require('./getFilterInfosAndTargetContentTypeFromQueryString'), - mime = require('mime'), - stream = require('stream'), - accepts = require('accepts'); +var Path = require('path'); + +var _ = require('underscore'); + +var httpErrors = require('httperrors'); + +var getFilterInfosAndTargetContentTypeFromQueryString = require('./getFilterInfosAndTargetContentTypeFromQueryString'); + +var mime = require('mime'); + +var stream = require('stream'); + +var accepts = require('accepts'); var hijackResponse = require('hijackresponse'); var isImageByExtension = {}; -Object.keys(mime._extensions).forEach(function (contentType) { - if (/^image\//.test(contentType)) { - var extension = mime._extensions[contentType]; - isImageByExtension[extension] = true; - } +Object.keys(mime._extensions).forEach(function(contentType) { + if (/^image\//.test(contentType)) { + var extension = mime._extensions[contentType]; + isImageByExtension[extension] = true; + } }); isImageByExtension.jpg = true; function isImageExtension(extension) { - return isImageByExtension[extension.toLowerCase()]; + return isImageByExtension[extension.toLowerCase()]; } -module.exports = function (options) { - options = options || {}; +module.exports = function(options) { + options = options || {}; - if (typeof options.sharpCache !== 'undefined' && getFilterInfosAndTargetContentTypeFromQueryString.sharp) { - getFilterInfosAndTargetContentTypeFromQueryString.sharp.cache(options.sharpCache); + if ( + typeof options.sharpCache !== 'undefined' && + getFilterInfosAndTargetContentTypeFromQueryString.sharp + ) { + getFilterInfosAndTargetContentTypeFromQueryString.sharp.cache( + options.sharpCache + ); + } + return function(req, res, next) { + // Polyfill req.accepts for browser-sync compatibility + if (typeof req.accepts !== 'function') { + req.accepts = function requestAccepts() { + var accept = accepts(req); + return accept.types.apply(accept, arguments); + }; } - return function (req, res, next) { - // Polyfill req.accepts for browser-sync compatibility - if (typeof req.accepts !== 'function') { - req.accepts = function requestAccepts() { - var accept = accepts(req); - return accept.types.apply(accept, arguments); - }; - } - var matchExtensionAndQueryString = req.url.match(/\.(\w+)\?(.*)$/); - var isMetadataRequest = matchExtensionAndQueryString && /^(?:.*&)?metadata(?:$|&|=true)/.test(matchExtensionAndQueryString[2]); - if (matchExtensionAndQueryString && (req.method === 'GET' || req.method === 'HEAD') && ((isImageExtension(matchExtensionAndQueryString[1]) && req.accepts('image/*')) || isMetadataRequest)) { - // Prevent If-None-Match revalidation with the downstream middleware with ETags that aren't suffixed with "-processimage": - var queryString = matchExtensionAndQueryString[2], - ifNoneMatch = req.headers['if-none-match']; - if (ifNoneMatch) { - var validIfNoneMatchTokens = ifNoneMatch.split(' ').filter(function (etag) { - return (/-processimage["-]/).test(etag); - }); - if (validIfNoneMatchTokens.length > 0) { - req.headers['if-none-match'] = validIfNoneMatchTokens.map(function (token) { - return token.replace(/-processimage(["-])/, '$1'); - }).join(' '); - } else { - delete req.headers['if-none-match']; - } - } - delete req.headers['if-modified-since']; // Prevent false positive conditional GETs after enabling processimage - // hijackResponse will never pass an error here - // eslint-disable-next-line handle-callback-err - hijackResponse(res, function (err, res) { - // Polyfill res.status for browser-sync compatibility - if (typeof res.status !== 'function') { - res.status = function status(statusCode) { - res.statusCode = statusCode; - return res; - }; - } + var matchExtensionAndQueryString = req.url.match(/\.(\w+)\?(.*)$/); + var isMetadataRequest = + matchExtensionAndQueryString && + /^(?:.*&)?metadata(?:$|&|=true)/.test(matchExtensionAndQueryString[2]); + if ( + matchExtensionAndQueryString && + (req.method === 'GET' || req.method === 'HEAD') && + ((isImageExtension(matchExtensionAndQueryString[1]) && + req.accepts('image/*')) || + isMetadataRequest) + ) { + // Prevent If-None-Match revalidation with the downstream middleware with ETags that aren't suffixed with "-processimage": + var queryString = matchExtensionAndQueryString[2]; - var sourceMetadata; - function makeFilterInfosAndTargetFormat() { - return getFilterInfosAndTargetContentTypeFromQueryString(queryString, _.defaults({ - allowOperation: options.allowOperation, - sourceFilePath: options.root && Path.resolve(options.root, req.url.substr(1)), - sourceMetadata: sourceMetadata - }, options)); - } + var ifNoneMatch = req.headers['if-none-match']; + if (ifNoneMatch) { + var validIfNoneMatchTokens = ifNoneMatch + .split(' ') + .filter(function(etag) { + return /-processimage["-]/.test(etag); + }); + if (validIfNoneMatchTokens.length > 0) { + req.headers['if-none-match'] = validIfNoneMatchTokens + .map(function(token) { + return token.replace(/-processimage(["-])/, '$1'); + }) + .join(' '); + } else { + delete req.headers['if-none-match']; + } + } + delete req.headers['if-modified-since']; // Prevent false positive conditional GETs after enabling processimage + // hijackResponse will never pass an error here + // eslint-disable-next-line handle-callback-err + hijackResponse( + res, + function(err, res) { + // Polyfill res.status for browser-sync compatibility + if (typeof res.status !== 'function') { + res.status = function status(statusCode) { + res.statusCode = statusCode; + return res; + }; + } - var contentLengthHeaderValue = res.getHeader('Content-Length'); - res.removeHeader('Content-Length'); - var oldETag = res.getHeader('ETag'), - newETag; - if (oldETag) { - newETag = oldETag.replace(/"$/g, '-processimage"'); - res.setHeader('ETag', newETag); - if (ifNoneMatch && ifNoneMatch.indexOf(newETag) !== -1) { - res.destroyHijacked(); - return res.status(304).end(); - } - } + var sourceMetadata; + function makeFilterInfosAndTargetFormat() { + return getFilterInfosAndTargetContentTypeFromQueryString( + queryString, + _.defaults( + { + allowOperation: options.allowOperation, + sourceFilePath: + options.root && + Path.resolve(options.root, req.url.substr(1)), + sourceMetadata: sourceMetadata + }, + options + ) + ); + } - function startProcessing(optionalFirstChunk) { - var hasEnded = false, - cleanedUp = false, - filters; - function cleanUp(doNotDestroyHijacked) { - if (!doNotDestroyHijacked) { - res.destroyHijacked(); - } - if (!cleanedUp) { - cleanedUp = true; - // the filters are unpiped after the error is passed to - // next. doing the unpiping before calling next caused - // the tests to fail on node 0.12 (not on 4.0 and 0.10). - if (res._readableState && res._readableState.buffer && res._readableState.buffer.length > 0) { - res._readableState.buffer = []; - } - if (filters) { - filters.forEach(function (filter) { - if (filter.unpipe) { - filter.unpipe(); - } - if (filter.kill) { - filter.kill(); - } else if (filter.destroy) { - filter.destroy(); - } else if (filter.resume) { - filter.resume(); - } - if (filter.end) { - filter.end(); - } - if (filter._readableState && filter._readableState.buffer && filter._readableState.buffer.length > 0) { - filter._readableState.buffer = []; - } - filter.removeAllListeners(); - // Some of the filters seem to emit error more than once sometimes: - filter.on('error', function () {}); - }); - filters = null; - } - res.removeAllListeners(); - } - } + var contentLengthHeaderValue = res.getHeader('Content-Length'); + res.removeHeader('Content-Length'); + var oldETag = res.getHeader('ETag'); - function handleError(err) { - if (!hasEnded) { - hasEnded = true; - if (err) { - if ('commandLine' in this) { - err.message = this.commandLine + ': ' + err.message; - } - if (err.message === 'Input buffer contains unsupported image format') { - err = new httpErrors.UnsupportedMediaType(err.message); - } - if (err.message === 'Input image exceeds pixel limit') { - // ?metadata with an unrecognized image format - err = new httpErrors.RequestEntityTooLarge(err.message); - } + var newETag; + if (oldETag) { + newETag = oldETag.replace(/"$/g, '-processimage"'); + res.setHeader('ETag', newETag); + if (ifNoneMatch && ifNoneMatch.indexOf(newETag) !== -1) { + res.destroyHijacked(); + return res.status(304).end(); + } + } - next(err); - } - res.unhijack(); - cleanUp(true); - } - } + function startProcessing(optionalFirstChunk) { + var hasEnded = false; - res.once('error', function () { - res.unhijack(); - next(500); - }); - res.once('close', cleanUp); - var targetContentType = filterInfosAndTargetFormat.targetContentType; - if (targetContentType) { - res.setHeader('Content-Type', targetContentType); - } - filters = []; - try { - filterInfosAndTargetFormat.filterInfos.forEach(function (filterInfo) { - var filter = filterInfo.create(); - if (Array.isArray(filter)) { - Array.prototype.push.apply(filters, filter); - } else { - filters.push(filter); - } - }); - } catch (e) { - return handleError(new httpErrors.BadRequest(e)); - } - if (filters.length === 0) { - filters = [new stream.PassThrough()]; + var cleanedUp = false; + + var filters; + function cleanUp(doNotDestroyHijacked) { + if (!doNotDestroyHijacked) { + res.destroyHijacked(); + } + if (!cleanedUp) { + cleanedUp = true; + // the filters are unpiped after the error is passed to + // next. doing the unpiping before calling next caused + // the tests to fail on node 0.12 (not on 4.0 and 0.10). + if ( + res._readableState && + res._readableState.buffer && + res._readableState.buffer.length > 0 + ) { + res._readableState.buffer = []; + } + if (filters) { + filters.forEach(function(filter) { + if (filter.unpipe) { + filter.unpipe(); } - if (options.debug) { - // Only used by the test suite to assert that the right engine is used to process gifs: - res.setHeader('X-Express-Processimage', filterInfosAndTargetFormat.filterInfos.map(function (filterInfo) { - return filterInfo.operationName; - }).join(',')); + if (filter.kill) { + filter.kill(); + } else if (filter.destroy) { + filter.destroy(); + } else if (filter.resume) { + filter.resume(); } - if (optionalFirstChunk) { - filters[0].write(optionalFirstChunk); + if (filter.end) { + filter.end(); } - for (var i = 0 ; i < filters.length ; i += 1) { - if (i < filters.length - 1) { - filters[i].pipe(filters[i + 1]); - } - // Some of the filters appear to emit error more than once: - filters[i].once('error', handleError); + if ( + filter._readableState && + filter._readableState.buffer && + filter._readableState.buffer.length > 0 + ) { + filter._readableState.buffer = []; } + filter.removeAllListeners(); + // Some of the filters seem to emit error more than once sometimes: + filter.on('error', function() {}); + }); + filters = null; + } + res.removeAllListeners(); + } + } + + function handleError(err) { + if (!hasEnded) { + hasEnded = true; + if (err) { + if ('commandLine' in this) { + err.message = this.commandLine + ': ' + err.message; + } + if ( + err.message === + 'Input buffer contains unsupported image format' + ) { + err = new httpErrors.UnsupportedMediaType(err.message); + } + if (err.message === 'Input image exceeds pixel limit') { + // ?metadata with an unrecognized image format + err = new httpErrors.RequestEntityTooLarge(err.message); + } - res.pipe(filters[0]); - filters[filters.length - 1].on('end', function () { - hasEnded = true; - cleanUp(); - }).pipe(res); + next(err); } + res.unhijack(); + cleanUp(true); + } + } - var contentType = res.getHeader('Content-Type'); - var filterInfosAndTargetFormat; - if (res.statusCode === 304) { - res.unhijack(); - } else if (isMetadataRequest || (contentType && (options.allowedImageSourceContentTypes ? options.allowedImageSourceContentTypes.indexOf(contentType) !== -1 : contentType.indexOf('image/') === 0))) { - sourceMetadata = { - contentType: contentType, - filesize: contentLengthHeaderValue && parseInt(contentLengthHeaderValue, 10), - etag: oldETag - }; + res.once('error', function() { + res.unhijack(); + next(500); + }); + res.once('close', cleanUp); + var targetContentType = + filterInfosAndTargetFormat.targetContentType; + if (targetContentType) { + res.setHeader('Content-Type', targetContentType); + } + filters = []; + try { + filterInfosAndTargetFormat.filterInfos.forEach(function( + filterInfo + ) { + var filter = filterInfo.create(); + if (Array.isArray(filter)) { + Array.prototype.push.apply(filters, filter); + } else { + filters.push(filter); + } + }); + } catch (e) { + return handleError(new httpErrors.BadRequest(e)); + } + if (filters.length === 0) { + filters = [new stream.PassThrough()]; + } + if (options.debug) { + // Only used by the test suite to assert that the right engine is used to process gifs: + res.setHeader( + 'X-Express-Processimage', + filterInfosAndTargetFormat.filterInfos + .map(function(filterInfo) { + return filterInfo.operationName; + }) + .join(',') + ); + } + if (optionalFirstChunk) { + filters[0].write(optionalFirstChunk); + } + for (var i = 0; i < filters.length; i += 1) { + if (i < filters.length - 1) { + filters[i].pipe(filters[i + 1]); + } + // Some of the filters appear to emit error more than once: + filters[i].once('error', handleError); + } - filterInfosAndTargetFormat = makeFilterInfosAndTargetFormat(); + res.pipe(filters[0]); + filters[filters.length - 1] + .on('end', function() { + hasEnded = true; + cleanUp(); + }) + .pipe(res); + } - if (filterInfosAndTargetFormat.filterInfos.length === 0) { - return res.unhijack(true); - } - if (options.secondGuessSourceContentType) { - var endOrCloseOrErrorBeforeFirstDataChunkListener = function (err) { - if (err) { - next(500); - } else { - res.end(); - } - }; - res.once('error', endOrCloseOrErrorBeforeFirstDataChunkListener); - res.once('end', endOrCloseOrErrorBeforeFirstDataChunkListener); - res.once('data', function (firstChunk) { - res.removeListener('end', endOrCloseOrErrorBeforeFirstDataChunkListener); - res.removeListener('close', endOrCloseOrErrorBeforeFirstDataChunkListener); - var detectedContentType; - if (firstChunk[0] === 0x47 && firstChunk[1] === 0x49 && firstChunk[2] === 0x46) { - detectedContentType = 'image/gif'; - } else if (firstChunk[0] === 0xff && firstChunk[1] === 0xd8) { - detectedContentType = 'image/jpeg'; - } else if (firstChunk[0] === 0x89 && firstChunk[1] === 0x50 && firstChunk[2] === 0x4e && firstChunk[3] === 0x47) { - detectedContentType = 'image/png'; - } else if (firstChunk[0] === 0x42 && firstChunk[1] === 0x4d) { - detectedContentType = 'image/bmp'; - } - if (detectedContentType && detectedContentType !== sourceMetadata.contentType) { - sourceMetadata.contentType = detectedContentType; - filterInfosAndTargetFormat = makeFilterInfosAndTargetFormat(); - } - startProcessing(firstChunk); - }); - } else { - startProcessing(); - } + var contentType = res.getHeader('Content-Type'); + var filterInfosAndTargetFormat; + if (res.statusCode === 304) { + res.unhijack(); + } else if ( + isMetadataRequest || + (contentType && + (options.allowedImageSourceContentTypes + ? options.allowedImageSourceContentTypes.indexOf( + contentType + ) !== -1 + : contentType.indexOf('image/') === 0)) + ) { + sourceMetadata = { + contentType: contentType, + filesize: + contentLengthHeaderValue && + parseInt(contentLengthHeaderValue, 10), + etag: oldETag + }; + + filterInfosAndTargetFormat = makeFilterInfosAndTargetFormat(); + + if (filterInfosAndTargetFormat.filterInfos.length === 0) { + return res.unhijack(true); + } + if (options.secondGuessSourceContentType) { + var endOrCloseOrErrorBeforeFirstDataChunkListener = function( + err + ) { + if (err) { + next(500); } else { - res.unhijack(); + res.end(); } - }, { disableBackpressure: true }); - next(); - } else { - next(); - } - }; + }; + res.once('error', endOrCloseOrErrorBeforeFirstDataChunkListener); + res.once('end', endOrCloseOrErrorBeforeFirstDataChunkListener); + res.once('data', function(firstChunk) { + res.removeListener( + 'end', + endOrCloseOrErrorBeforeFirstDataChunkListener + ); + res.removeListener( + 'close', + endOrCloseOrErrorBeforeFirstDataChunkListener + ); + var detectedContentType; + if ( + firstChunk[0] === 0x47 && + firstChunk[1] === 0x49 && + firstChunk[2] === 0x46 + ) { + detectedContentType = 'image/gif'; + } else if (firstChunk[0] === 0xff && firstChunk[1] === 0xd8) { + detectedContentType = 'image/jpeg'; + } else if ( + firstChunk[0] === 0x89 && + firstChunk[1] === 0x50 && + firstChunk[2] === 0x4e && + firstChunk[3] === 0x47 + ) { + detectedContentType = 'image/png'; + } else if (firstChunk[0] === 0x42 && firstChunk[1] === 0x4d) { + detectedContentType = 'image/bmp'; + } + if ( + detectedContentType && + detectedContentType !== sourceMetadata.contentType + ) { + sourceMetadata.contentType = detectedContentType; + filterInfosAndTargetFormat = makeFilterInfosAndTargetFormat(); + } + startProcessing(firstChunk); + }); + } else { + startProcessing(); + } + } else { + res.unhijack(); + } + }, + { disableBackpressure: true } + ); + next(); + } else { + next(); + } + }; }; diff --git a/test/browsersync-compat.js b/test/browsersync-compat.js index 167dad9..7b7d5d2 100644 --- a/test/browsersync-compat.js +++ b/test/browsersync-compat.js @@ -7,91 +7,116 @@ var processImage = require('../lib/processImage'); var serverPort = '9999'; -expect.use(require('unexpected-http')) - .use(require('unexpected-image')); +expect.use(require('unexpected-http')).use(require('unexpected-image')); -describe('browser-sync compatibility', function () { - before(function (done) { - bs.init({ - port: serverPort, - server: root, - open: false, - logLevel: 'silent', - middleware: [ - processImage({ root: root }) - ] - }, done); - }); +describe('browser-sync compatibility', function() { + before(function(done) { + bs.init( + { + port: serverPort, + server: root, + open: false, + logLevel: 'silent', + middleware: [processImage({ root: root })] + }, + done + ); + }); - after(function () { - bs.exit(); - }); + after(function() { + bs.exit(); + }); + it('should not mess with request for non-image file', function() { + return expect( + `GET http://localhost:${serverPort}/something.txt`, + 'to yield response', + { + headers: { + 'Content-Type': 'text/plain; charset=UTF-8' + }, + body: 'foo\n' + } + ); + }); - it('should not mess with request for non-image file', function () { - return expect(`GET http://localhost:${serverPort}/something.txt`, 'to yield response', { - headers: { - 'Content-Type': 'text/plain; charset=UTF-8' - }, - body: 'foo\n' - }); - }); + it('should not mess with request for image with no query string', function() { + return expect( + `GET http://localhost:${serverPort}/ancillaryChunks.png`, + 'to yield response', + { + headers: { + 'Content-Type': 'image/png' + }, + body: expect.it('to have length', 3711) + } + ); + }); - it('should not mess with request for image with no query string', function () { - return expect(`GET http://localhost:${serverPort}/ancillaryChunks.png`, 'to yield response', { - headers: { - 'Content-Type': 'image/png' - }, - body: expect.it('to have length', 3711) - }); - }); + it('should not mess with request for image with an unsupported operation in the query string', function() { + return expect( + `GET http://localhost:${serverPort}/ancillaryChunks.png?foo=bar`, + 'to yield response', + { + headers: { + 'Content-Type': 'image/png' + }, + body: expect.it('to have length', 3711) + } + ); + }); - it('should not mess with request for image with an unsupported operation in the query string', function () { - return expect(`GET http://localhost:${serverPort}/ancillaryChunks.png?foo=bar`, 'to yield response', { - headers: { - 'Content-Type': 'image/png' - }, - body: expect.it('to have length', 3711) - }); + it('should return a 304 status code when requesting the same image with unchanged modifications', function() { + return expect( + `GET http://localhost:${serverPort}/ancillaryChunks.png?foo=bar`, + 'to yield response', + { + statusCode: 200, + headers: { + 'Content-Type': 'image/png' + }, + body: expect.it('to have length', 3711) + } + ).then(function(context) { + var etag = context.httpResponse.headers.get('ETag'); + return expect( + { + url: `GET http://localhost:${serverPort}/ancillaryChunks.png?foo=bar`, + headers: { + 'If-None-Match': etag + } + }, + 'to yield response', + { + statusCode: 304, + headers: expect.it('to be empty'), + body: expect.it('to be', '') + } + ); }); + }); - it('should return a 304 status code when requesting the same image with unchanged modifications', function () { - return expect(`GET http://localhost:${serverPort}/ancillaryChunks.png?foo=bar`, 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/png' - }, - body: expect.it('to have length', 3711) - }).then(function (context) { - var etag = context.httpResponse.headers.get('ETag'); - return expect({ - url: `GET http://localhost:${serverPort}/ancillaryChunks.png?foo=bar`, - headers: { - 'If-None-Match': etag - } - }, 'to yield response', { - statusCode: 304, - headers: expect.it('to be empty'), - body: expect.it('to be', '') - }); - }); - }); - - it('should run the image through pngcrush when the pngcrush CGI param is specified', function () { - return expect(`GET http://localhost:${serverPort}/ancillaryChunks.png?pngcrush=-rem+alla`, 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/png' - }, - body: expect.it('to have metadata satisfying', { - format: 'PNG', - size: { - width: 400, - height: 20 - } - }).and('to satisfy', function (body) { - expect(body.length, 'to be within', 1, 3711); - }) - }); - }); + it('should run the image through pngcrush when the pngcrush CGI param is specified', function() { + return expect( + `GET http://localhost:${serverPort}/ancillaryChunks.png?pngcrush=-rem+alla`, + 'to yield response', + { + statusCode: 200, + headers: { + 'Content-Type': 'image/png' + }, + body: expect + .it('to have metadata satisfying', { + format: 'PNG', + size: { + width: 400, + height: 20 + } + }) + .and('to satisfy', function(body) { + expect(body.length, 'to be within', 1, 3711); + }) + } + ); + }); }); diff --git a/test/getFilterInfosAndTargetContentTypeFromQueryString.js b/test/getFilterInfosAndTargetContentTypeFromQueryString.js index ce2cc7c..cdc3aac 100644 --- a/test/getFilterInfosAndTargetContentTypeFromQueryString.js +++ b/test/getFilterInfosAndTargetContentTypeFromQueryString.js @@ -3,152 +3,169 @@ var getFilterInfosAndTargetContentTypeFromQueryString = require('../lib/getFilte var expect = require('unexpected'); -describe('getFilterInfosAndTargetContentTypeFromQueryString', function () { - it('should make the right engine choice even if the source Content-Type is not available until filterInfo.create is called', function () { - var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString('resize=10,10', { - sourceMetadata: { - contentType: 'image/gif' - } - }); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - operationNames: [ 'gifsicle' ], - filterInfos: [ - { - targetContentType: 'image/gif', - operationName: 'gifsicle' - } - ] - }); +describe('getFilterInfosAndTargetContentTypeFromQueryString', function() { + it('should make the right engine choice even if the source Content-Type is not available until filterInfo.create is called', function() { + var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( + 'resize=10,10', + { + sourceMetadata: { + contentType: 'image/gif' + } + } + ); + + filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); + + expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { + operationNames: ['gifsicle'], + filterInfos: [ + { + targetContentType: 'image/gif', + operationName: 'gifsicle' + } + ] + }); + }); + + describe('gm:background', function() { + it('should match #rrggbb', function() { + var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( + 'background=#000000' + ); + + filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); + + expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { + filterInfos: [ + { + operations: [ + { + name: 'background', + usedQueryStringFragment: 'background=#000000' + } + ], + leftOverQueryStringFragments: undefined + } + ] + }); }); - describe('gm:background', function () { - it('should match #rrggbb', function () { - var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString('background=#000000'); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - filterInfos: [ - { - operations: [ - { - name: 'background', - usedQueryStringFragment: 'background=#000000' - } - ], - leftOverQueryStringFragments: undefined - } - ] - }); - }); - - it('should match #rgb', function () { - var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString('background=#000'); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - filterInfos: [ - { - operations: [ - { - name: 'background', - usedQueryStringFragment: 'background=#000' - } - ], - leftOverQueryStringFragments: undefined - } - ] - }); - }); + it('should match #rgb', function() { + var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( + 'background=#000' + ); + + filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); + + expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { + filterInfos: [ + { + operations: [ + { + name: 'background', + usedQueryStringFragment: 'background=#000' + } + ], + leftOverQueryStringFragments: undefined + } + ] + }); + }); - it('should match #rrggbbaa', function () { - var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString('background=#00000000'); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - filterInfos: [ - { - operations: [ - { - name: 'background', - usedQueryStringFragment: 'background=#00000000' - } - ], - leftOverQueryStringFragments: undefined - } - ] - }); - }); + it('should match #rrggbbaa', function() { + var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( + 'background=#00000000' + ); + + filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); + + expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { + filterInfos: [ + { + operations: [ + { + name: 'background', + usedQueryStringFragment: 'background=#00000000' + } + ], + leftOverQueryStringFragments: undefined + } + ] + }); + }); - it('should match #rgba', function () { - var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString('background=#0000'); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - filterInfos: [ - { - operations: [ - { - name: 'background', - usedQueryStringFragment: 'background=#0000' - } - ], - leftOverQueryStringFragments: undefined - } - ] - }); - }); + it('should match #rgba', function() { + var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( + 'background=#0000' + ); + + filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); + + expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { + filterInfos: [ + { + operations: [ + { + name: 'background', + usedQueryStringFragment: 'background=#0000' + } + ], + leftOverQueryStringFragments: undefined + } + ] + }); + }); + }); + + describe('sharp', function() { + it('should allow using setFormat to specify the output format', function() { + var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( + 'setFormat=png', + { + defaultEngineName: 'sharp', + sourceMetadata: { + contentType: 'image/jpeg' + } + } + ); + + filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); + + expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { + targetContentType: 'image/png', + operationNames: ['sharp'], + filterInfos: [ + { + operationName: 'sharp' + } + ] + }); }); - describe('sharp', function () { - it('should allow using setFormat to specify the output format', function () { - var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString('setFormat=png', { - defaultEngineName: 'sharp', - sourceMetadata: { - contentType: 'image/jpeg' - } - }); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - targetContentType: 'image/png', - operationNames: [ 'sharp' ], - filterInfos: [ - { - operationName: 'sharp' - } - ] - }); - }); + describe('with a conversion to image/gif', function() { + it('should fall back to another engine', function() { + var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( + 'setFormat=gif', + { + defaultEngineName: 'sharp', + sourceMetadata: { + contentType: 'image/jpeg' + } + } + ); + + filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - describe('with a conversion to image/gif', function () { - it('should fall back to another engine', function () { - var filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString('setFormat=gif', { - defaultEngineName: 'sharp', - sourceMetadata: { - contentType: 'image/jpeg' - } - }); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - targetContentType: 'image/gif', - operationNames: [ 'gm' ], - filterInfos: [ - { - operationName: 'gm' - } - ] - }); - }); + expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { + targetContentType: 'image/gif', + operationNames: ['gm'], + filterInfos: [ + { + operationName: 'gm' + } + ] }); + }); }); + }); }); diff --git a/test/processImage.js b/test/processImage.js index 17ad709..15b0061 100644 --- a/test/processImage.js +++ b/test/processImage.js @@ -1,1303 +1,1691 @@ /*global describe, it, beforeEach, afterEach, __dirname*/ -var express = require('express'), - fs = require('fs'), - http = require('http'), - pathModule = require('path'), - unexpected = require('unexpected'), - sinon = require('sinon'), - Stream = require('stream'), - processImage = require('../lib/processImage'), - root = pathModule.resolve(__dirname, '..', 'testdata') + '/', - sharp = require('sharp'); - -describe('express-processimage', function () { - var config; - var sandbox; - beforeEach(function () { - config = { root: root, filters: {} }; - sandbox = sinon.createSandbox(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - var expect = unexpected.clone() - .use(require('unexpected-express')) - .use(require('unexpected-http')) - .use(require('unexpected-image')) - .use(require('unexpected-resemble')) - .use(require('unexpected-sinon')) - .use(require('magicpen-prism')) - .addAssertion(' to yield response ', function (expect, subject, value) { - return expect( - express() - .use(processImage(config)) - .use(express.static(root)), - 'to yield exchange', { - request: subject, - response: value - } - ); - }) - .addAssertion(' [when] converted to PNG ', function (expect, subject) { - expect.errorMode = 'bubble'; - return sharp(subject).png().toBuffer().then(function (pngBuffer) { - return expect.shift(pngBuffer); - }); - }); - - it('should not mess with request for non-image file', function () { - return expect('GET /something.txt', 'to yield response', { - headers: { - 'Content-Type': 'text/plain; charset=UTF-8' - }, - body: 'foo\n' +var express = require('express'); + +var fs = require('fs'); + +var http = require('http'); + +var pathModule = require('path'); + +var unexpected = require('unexpected'); + +var sinon = require('sinon'); + +var Stream = require('stream'); + +var processImage = require('../lib/processImage'); + +var root = pathModule.resolve(__dirname, '..', 'testdata') + '/'; + +var sharp = require('sharp'); + +describe('express-processimage', function() { + var config; + var sandbox; + beforeEach(function() { + config = { root: root, filters: {} }; + sandbox = sinon.createSandbox(); + }); + + afterEach(function() { + sandbox.restore(); + }); + + var expect = unexpected + .clone() + .use(require('unexpected-express')) + .use(require('unexpected-http')) + .use(require('unexpected-image')) + .use(require('unexpected-resemble')) + .use(require('unexpected-sinon')) + .use(require('magicpen-prism')) + .addAssertion(' to yield response ', function( + expect, + subject, + value + ) { + return expect( + express() + .use(processImage(config)) + .use(express.static(root)), + 'to yield exchange', + { + request: subject, + response: value + } + ); + }) + .addAssertion(' [when] converted to PNG ', function( + expect, + subject + ) { + expect.errorMode = 'bubble'; + return sharp(subject) + .png() + .toBuffer() + .then(function(pngBuffer) { + return expect.shift(pngBuffer); }); }); - it('should not mess with request for image with no query string', function () { - return expect('GET /ancillaryChunks.png', 'to yield response', { - headers: { - 'Content-Type': 'image/png' - }, - body: expect.it('to have length', 3711) - }); + it('should not mess with request for non-image file', function() { + return expect('GET /something.txt', 'to yield response', { + headers: { + 'Content-Type': 'text/plain; charset=UTF-8' + }, + body: 'foo\n' }); - - it('should not mess with request for image with an unsupported operation in the query string', function () { - return expect('GET /ancillaryChunks.png?foo=bar', 'to yield response', { - headers: { - 'Content-Type': 'image/png' - }, - body: expect.it('to have length', 3711) - }); + }); + + it('should not mess with request for image with no query string', function() { + return expect('GET /ancillaryChunks.png', 'to yield response', { + headers: { + 'Content-Type': 'image/png' + }, + body: expect.it('to have length', 3711) }); - - it('refuses to process an image whose dimensions exceed maxInputPixels', function () { - config.maxInputPixels = 100000; - return expect('GET /hugearea.png?resize=100,100', 'to yield response', 413); + }); + + it('should not mess with request for image with an unsupported operation in the query string', function() { + return expect('GET /ancillaryChunks.png?foo=bar', 'to yield response', { + headers: { + 'Content-Type': 'image/png' + }, + body: expect.it('to have length', 3711) + }); + }); + + it('refuses to process an image whose dimensions exceed maxInputPixels', function() { + config.maxInputPixels = 100000; + return expect('GET /hugearea.png?resize=100,100', 'to yield response', 413); + }); + + describe('with the sharp engine', function() { + it('should resize by specifying a bounding box', function() { + return expect('GET /turtle.jpg?resize=500,1000', 'to yield response', { + body: expect.it('to have metadata satisfying', { + size: { + width: 500, + height: 441 + } + }) + }); }); - describe('with the sharp engine', function () { - it('should resize by specifying a bounding box', function () { - return expect('GET /turtle.jpg?resize=500,1000', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 500, - height: 441 - } - }) - }); - }); - - describe('when omitting the height', function () { - it('should do a proportional resize to the given width', function () { - return expect('GET /turtle.jpg?resize=500,', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 500, - height: 441 - } - }) - }); - }); - - describe('without a trailing comma', function () { - it('should do a proportional resize to the given width', function () { - return expect('GET /turtle.jpg?resize=500', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 500, - height: 441 - } - }) - }); - }); - }); - - describe('with a maxOutputPixels setting in place', function () { - it('should limit the size of the bounding box based on the maxOutputPixels value', function () { - config.maxOutputPixels = 250000; - return expect('GET /turtle.jpg?resize=2000,', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 142, - height: 125 - } - }) - }); - }); - }); - }); - - describe('when omitting the width', function () { - it('should do a proportional resize to the given height', function () { - return expect('GET /turtle.jpg?resize=,500', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 567, - height: 500 - } - }) - }); - }); - - describe('with a maxOutputPixels setting in place', function () { - it('should limit the size of the bounding box based on the maxOutputPixels value', function () { - config.maxOutputPixels = 250000; - return expect('GET /turtle.jpg?resize=,2000', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 125, - height: 110 - } - }) - }); - }); - }); - }); - - it('should do an entropy-based crop', function () { - return expect('GET /turtle.jpg?resize=100,200&crop=entropy', 'to yield response', { - body: expect.it('to resemble', pathModule.resolve(__dirname, '..', 'testdata', 'turtleCroppedEntropy100x200.jpg')) - .and('to have metadata satisfying', { - size: { - width: 100, - height: 200 - } - }) - }); + describe('when omitting the height', function() { + it('should do a proportional resize to the given width', function() { + return expect('GET /turtle.jpg?resize=500,', 'to yield response', { + body: expect.it('to have metadata satisfying', { + size: { + width: 500, + height: 441 + } + }) }); + }); - it('should do an attention-based crop', function () { - return expect('GET /turtle.jpg?resize=100,200&crop=attention', 'to yield response', { - body: expect.it('to resemble', pathModule.resolve(__dirname, '..', 'testdata', 'turtleCroppedAttention100x200.jpg')) - .and('to have metadata satisfying', { - size: { - width: 100, - height: 200 - } - }) - }); + describe('without a trailing comma', function() { + it('should do a proportional resize to the given width', function() { + return expect('GET /turtle.jpg?resize=500', 'to yield response', { + body: expect.it('to have metadata satisfying', { + size: { + width: 500, + height: 441 + } + }) + }); }); + }); - // https://github.com/papandreou/express-processimage/issues/23 - describe('when the quality and progressiveness of the image is being adjusted', function () { - it('should work and not log deprecation warnings when there is no explicit conversion', function () { - sandbox.spy(console, 'error'); - return expect('GET /turtle.jpg?quality=10&progressive', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 481, - height: 424 - }, - Interlace: 'Line', - Filesize: expect.it('to match', /Ki?$/).and('when passed as parameter to', parseFloat, 'to be less than', 10) - }) - }) - .then(() => expect(console.error, 'to have no calls satisfying', () => console.error(/DeprecationWarning/))); - }); - - it('should work and not log deprecation warnings when there is an explicit conversion', function () { - sandbox.spy(console, 'error'); - return expect('GET /turtle.jpg?jpeg&quality=10&progressive', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 481, - height: 424 - }, - Interlace: 'Line', - Filesize: expect.it('to match', /Ki?$/).and('when passed as parameter to', parseFloat, 'to be less than', 10) - }) - }) - .then(() => expect(console.error, 'to have no calls satisfying', () => console.error(/DeprecationWarning/))); - }); + describe('with a maxOutputPixels setting in place', function() { + it('should limit the size of the bounding box based on the maxOutputPixels value', function() { + config.maxOutputPixels = 250000; + return expect('GET /turtle.jpg?resize=2000,', 'to yield response', { + body: expect.it('to have metadata satisfying', { + size: { + width: 142, + height: 125 + } + }) + }); }); + }); }); - describe('with the sharp engine', function () { - it('should resize by specifying a bounding box', function () { - return expect('GET /turtle.jpg?sharp&resize=500,1000', 'to yield response', { - body: expect.it('to have metadata satisfying', { - size: { - width: 500, - height: 441 - } - }) - }); + describe('when omitting the width', function() { + it('should do a proportional resize to the given height', function() { + return expect('GET /turtle.jpg?resize=,500', 'to yield response', { + body: expect.it('to have metadata satisfying', { + size: { + width: 567, + height: 500 + } + }) }); - }); + }); - it('should run the image through pngcrush when the pngcrush CGI param is specified', function () { - return expect('GET /ancillaryChunks.png?pngcrush=-rem+alla', 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/png' - }, + describe('with a maxOutputPixels setting in place', function() { + it('should limit the size of the bounding box based on the maxOutputPixels value', function() { + config.maxOutputPixels = 250000; + return expect('GET /turtle.jpg?resize=,2000', 'to yield response', { body: expect.it('to have metadata satisfying', { - format: 'PNG', - size: { - width: 400, - height: 20 - } - }).and('to satisfy', function (body) { - expect(body.length, 'to be within', 1, 3711); + size: { + width: 125, + height: 110 + } }) + }); }); + }); }); - it('should run the image through pngquant when the pngquant CGI param is specified', function () { - return expect('GET /purplealpha24bit.png?pngquant', 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/png' - }, - body: expect.it('to have metadata satisfying', { - format: 'PNG', - size: { - width: 100, - height: 100 - } - }).and('to satisfy', function (body) { - expect(body.length, 'to be within', 1, 8285); + it('should do an entropy-based crop', function() { + return expect( + 'GET /turtle.jpg?resize=100,200&crop=entropy', + 'to yield response', + { + body: expect + .it( + 'to resemble', + pathModule.resolve( + __dirname, + '..', + 'testdata', + 'turtleCroppedEntropy100x200.jpg' + ) + ) + .and('to have metadata satisfying', { + size: { + width: 100, + height: 200 + } }) - }); + } + ); }); - it('should run the image through jpegtran when the jpegtran CGI param is specified', function () { - return expect('GET /turtle.jpg?jpegtran=-grayscale,-flip,horizontal', 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/jpeg' - }, - body: expect.it('to have metadata satisfying', { - format: 'JPEG', - 'Channel Depths': { - Gray: '8 bits' - }, - size: { - width: 481, - height: 424 - } - }).and('to satisfy', function (body) { - expect(body.length, 'to be within', 1, 105836); + it('should do an attention-based crop', function() { + return expect( + 'GET /turtle.jpg?resize=100,200&crop=attention', + 'to yield response', + { + body: expect + .it( + 'to resemble', + pathModule.resolve( + __dirname, + '..', + 'testdata', + 'turtleCroppedAttention100x200.jpg' + ) + ) + .and('to have metadata satisfying', { + size: { + width: 100, + height: 200 + } }) - }); + } + ); }); - it('should run the image through graphicsmagick when methods exposed by the gm module are added as CGI params', function () { - return expect('GET /turtle.jpg?gm&resize=340,300', 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/jpeg' - }, + // https://github.com/papandreou/express-processimage/issues/23 + describe('when the quality and progressiveness of the image is being adjusted', function() { + it('should work and not log deprecation warnings when there is no explicit conversion', function() { + sandbox.spy(console, 'error'); + return expect( + 'GET /turtle.jpg?quality=10&progressive', + 'to yield response', + { body: expect.it('to have metadata satisfying', { - format: 'JPEG', - size: { - width: 340, - height: 300 - } - }).and('to satisfy', function (body) { - expect(body.length, 'to be within', 1, 105836); - expect(body.slice(0, 10), 'to equal', new Buffer([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46])); + size: { + width: 481, + height: 424 + }, + Interlace: 'Line', + Filesize: expect + .it('to match', /Ki?$/) + .and( + 'when passed as parameter to', + parseFloat, + 'to be less than', + 10 + ) }) - }); - }); + } + ).then(() => + expect(console.error, 'to have no calls satisfying', () => + console.error(/DeprecationWarning/) + ) + ); + }); - it('should run the image through sharp when methods exposed by the sharp module are added as CGI params', function () { - return expect('GET /turtle.jpg?sharp&resize=340,300&png', 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/png' - }, + it('should work and not log deprecation warnings when there is an explicit conversion', function() { + sandbox.spy(console, 'error'); + return expect( + 'GET /turtle.jpg?jpeg&quality=10&progressive', + 'to yield response', + { body: expect.it('to have metadata satisfying', { - format: 'PNG', - size: { - width: 340, - height: 300 - } + size: { + width: 481, + height: 424 + }, + Interlace: 'Line', + Filesize: expect + .it('to match', /Ki?$/) + .and( + 'when passed as parameter to', + parseFloat, + 'to be less than', + 10 + ) }) - }); + } + ).then(() => + expect(console.error, 'to have no calls satisfying', () => + console.error(/DeprecationWarning/) + ) + ); + }); }); - - it('should run the image through svgfilter when the svgfilter parameter is specified', function () { - return expect('GET /dialog-information.svg?svgfilter=--runScript=addBogusElement.js,--bogusElementId=theBogusElementId', 'to yield response', { - statusCode: 200, - headers: { - 'Content-Type': 'image/svg+xml', - ETag: /"\w+-\w+-processimage"$/ + }); + + describe('with the sharp engine', function() { + it('should resize by specifying a bounding box', function() { + return expect( + 'GET /turtle.jpg?sharp&resize=500,1000', + 'to yield response', + { + body: expect.it('to have metadata satisfying', { + size: { + width: 500, + height: 441 + } + }) + } + ); + }); + }); + + it('should run the image through pngcrush when the pngcrush CGI param is specified', function() { + return expect( + 'GET /ancillaryChunks.png?pngcrush=-rem+alla', + 'to yield response', + { + statusCode: 200, + headers: { + 'Content-Type': 'image/png' + }, + body: expect + .it('to have metadata satisfying', { + format: 'PNG', + size: { + width: 400, + height: 20 + } + }) + .and('to satisfy', function(body) { + expect(body.length, 'to be within', 1, 3711); + }) + } + ); + }); + + it('should run the image through pngquant when the pngquant CGI param is specified', function() { + return expect('GET /purplealpha24bit.png?pngquant', 'to yield response', { + statusCode: 200, + headers: { + 'Content-Type': 'image/png' + }, + body: expect + .it('to have metadata satisfying', { + format: 'PNG', + size: { + width: 100, + height: 100 + } + }) + .and('to satisfy', function(body) { + expect(body.length, 'to be within', 1, 8285); + }) + }); + }); + + it('should run the image through jpegtran when the jpegtran CGI param is specified', function() { + return expect( + 'GET /turtle.jpg?jpegtran=-grayscale,-flip,horizontal', + 'to yield response', + { + statusCode: 200, + headers: { + 'Content-Type': 'image/jpeg' + }, + body: expect + .it('to have metadata satisfying', { + format: 'JPEG', + 'Channel Depths': { + Gray: '8 bits' }, - body: expect.it('to match', /