diff --git a/lib/getFilterInfosAndTargetContentTypeFromQueryString.js b/lib/getFilterInfosAndTargetContentTypeFromQueryString.js deleted file mode 100644 index a16438c..0000000 --- a/lib/getFilterInfosAndTargetContentTypeFromQueryString.js +++ /dev/null @@ -1,1181 +0,0 @@ -const Stream = require('stream'); -const _ = require('underscore'); -const gm = require('gm-papandreou'); -const mime = require('mime'); -const createAnimatedGifDetector = require('animated-gif-detector'); -const exifReader = require('exif-reader'); -const icc = require('icc'); -let sharp; -try { - sharp = require('sharp'); -} catch (e) {} - -let Gifsicle; -const isOperationByEngineNameAndName = { gm: {} }; -const filterConstructorByOperationName = {}; -const errors = require('./errors'); - -[ - 'PngQuant', - 'PngCrush', - 'OptiPng', - 'JpegTran', - 'Inkscape', - 'SvgFilter' -].forEach(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(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' || - contentType === 'image/x-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(formatName => { - isOperationByEngineNameAndName.gm[formatName] = true; -}); - -isOperationByEngineNameAndName.gm.extract = true; - -try { - Gifsicle = require('gifsicle-stream'); -} catch (e) {} - -const 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(sharpOperationName => { - isOperationByEngineNameAndName.sharp[sharpOperationName] = true; - }); -} - -const engineNamesByOperationName = {}; - -Object.keys(isOperationByEngineNameAndName).forEach(engineName => { - Object.keys(isOperationByEngineNameAndName[engineName]).forEach( - operationName => { - (engineNamesByOperationName[operationName] = - engineNamesByOperationName[operationName] || []).push(engineName); - } - ); -}); - -function isNumberWithin(num, min, max) { - return typeof num === 'number' && num >= min && num <= max; -} - -function isValidOperation(name, args) { - const maxDimension = 16384; - switch (name) { - case 'crop': - 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)) - ); - 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) - ); - 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) - ); - case 'interpolateWith': - 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] - ) - ); - case 'blur': - 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') - ); - case 'threshold': - 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)) - ); - case 'quality': - 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)) - ); - case 'compressionLevel': - return args.length === 1 && isNumberWithin(args[0], 0, 9); - case 'png': - case 'jpeg': - case 'gif': - case 'webp': - case 'withoutEnlargement': - case 'progressive': - case 'ignoreAspectRatio': - case 'embed': - case 'max': - case 'min': - case 'negate': - case 'flatten': - case 'flip': - case 'flop': - case 'grayscale': - case 'greyscale': - case 'normalize': - case 'withMetadata': - case 'withoutChromaSubsampling': - case 'withoutAdaptiveFiltering': - case 'trellisQuantization': - case 'trellisQuantisation': - case 'overshootDeringing': - case 'optimizeScans': - case 'optimiseScans': - return args.length === 0; - // Not supported: overlayWith - - case 'metadata': - return args.length === 0 || (args.length === 1 && args[0] === true); - - // Engines: - case 'sharp': - case 'gm': - return args.length === 0; - - // FIXME: Add validation code for all the below. - // https://github.com/papandreou/express-processimage/issues/4 - // Other engines: - case 'pngcrush': - case 'pngquant': - case 'jpegtran': - case 'optipng': - case 'svgfilter': - case 'inkscape': - return true; - - // Graphicsmagick specific operations: - // FIXME: Add validation code for all the below. - // https://github.com/papandreou/express-processimage/issues/4 - case 'setFormat': - case 'identify': - case 'selectFrame': - case 'subCommand': - case 'adjoin': - case 'affine': - case 'alpha': - case 'append': - case 'authenticate': - case 'average': - case 'backdrop': - case 'blackThreshold': - case 'bluePrimary': - case 'border': - case 'borderColor': - case 'box': - case 'channel': - case 'chop': - case 'clip': - case 'coalesce': - case 'colorize': - case 'colorMap': - case 'compose': - case 'compress': - case 'convolve': - case 'createDirectories': - case 'deconstruct': - case 'define': - case 'delay': - case 'displace': - case 'display': - case 'dispose': - case 'dissolve': - case 'encoding': - case 'endian': - case 'file': - case 'foreground': - case 'frame': - case 'fuzz': - case 'gaussian': - case 'geometry': - case 'greenPrimary': - case 'highlightColor': - case 'highlightStyle': - case 'iconGeometry': - case 'intent': - case 'lat': - case 'level': - case 'list': - case 'log': - case 'loop': - case 'map': - case 'mask': - case 'matte': - case 'matteColor': - case 'maximumError': - case 'mode': - case 'monitor': - case 'mosaic': - case 'motionBlur': - case 'name': - case 'noop': - case 'opaque': - case 'operator': - case 'orderedDither': - case 'outputDirectory': - case 'page': - case 'pause': - case 'pen': - case 'ping': - case 'pointSize': - case 'preview': - case 'process': - case 'profile': - case 'progress': - case 'randomThreshold': - case 'recolor': - case 'redPrimary': - case 'remote': - case 'render': - case 'repage': - case 'sample': - case 'samplingFactor': - case 'scene': - case 'scenes': - case 'screen': - case 'set': - case 'segment': - case 'shade': - case 'shadow': - case 'sharedMemory': - case 'shave': - case 'shear': - case 'silent': - case 'rawSize': - case 'snaps': - case 'stegano': - case 'stereo': - case 'textFont': - case 'texture': - case 'thumbnail': - case 'title': - case 'transform': - case 'transparent': - case 'treeDepth': - case 'update': - case 'units': - case 'unsharp': - case 'usePixmap': - case 'view': - case 'virtualPixel': - case 'visual': - case 'watermark': - case 'wave': - case 'whitePoint': - case 'whiteThreshold': - case 'window': - case 'windowGroup': - case 'strip': - case 'interlace': - case 'resizeExact': - case 'scale': - case 'filter': - case 'density': - case 'noProfile': - case 'resample': - case 'magnify': - case 'minify': - case 'charcoal': - case 'modulate': - case 'antialias': - case 'bitdepth': - case 'colors': - case 'colorspace': - case 'comment': - case 'contrast': - case 'cycle': - case 'despeckle': - case 'dither': - case 'monochrome': - case 'edge': - case 'emboss': - case 'enhance': - case 'equalize': - case 'implode': - case 'label': - case 'limit': - case 'median': - case 'negative': - case 'noise': - case 'paint': - case 'raise': - case 'lower': - case 'region': - case 'roll': - case 'solarize': - case 'spread': - case 'swirl': - case 'type': - case 'trim': - case 'extent': - case 'gravity': - case 'fill': - case 'stroke': - case 'strokeWidth': - case 'font': - case 'fontSize': - case 'draw': - case 'drawPoint': - case 'drawLine': - case 'drawRectangle': - case 'drawArc': - case 'drawEllipse': - case 'drawCircle': - case 'drawPolyline': - case 'drawPolygon': - case 'drawBezier': - case 'drawText': - case 'setDraw': - case 'thumb': - case 'thumbExact': - case 'morph': - case 'sepia': - case 'autoOrient': - case 'in': - case 'out': - case 'preprocessor': - case 'addSrcFormatter': - case 'inputIs': - case 'compare': - case 'composite': - case 'montage': - return true; - default: - return false; - } -} - -module.exports = function getFilterInfosAndTargetContentTypeFromQueryString( - queryString, - options -) { - options = options || {}; - const filters = options.filters || {}; - const filterInfos = []; - let defaultEngineName = - options.defaultEngineName || (sharp && 'sharp') || 'gm'; - let currentEngineName; - let operations = []; - const operationNames = []; - const usedQueryStringFragments = []; - const leftOverQueryStringFragments = []; - const sourceMetadata = options.sourceMetadata || {}; - let targetContentType = sourceMetadata.contentType; - const 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) { - let engineName = currentEngineName; - const operationIndex = operationNames.length; - operationNames.push('sharpOrGm'); - filterInfos.push({ - operationName: 'sharpOrGm', - operations, - usedQueryStringFragments: operations.map( - operation => operation.usedQueryStringFragment - ), - create() { - const sourceContentType = - (this.operations[0] && this.operations[0].sourceContentType) || - sourceMetadata.contentType; - if ( - sourceContentType === 'image/gif' && - !this.operations.some( - operation => - 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( - operation => - 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; - - const operations = this.operations; - this.operationName = engineName; - operationNames[operationIndex] = engineName; - if (engineName === 'gifsicle') { - let gifsicleArgs = []; - let seenOperationThatMustComeBeforeExtract = false; - const gifsicles = []; - const flush = () => { - if (gifsicleArgs.length > 0) { - gifsicles.push(new Gifsicle(gifsicleArgs)); - seenOperationThatMustComeBeforeExtract = false; - gifsicleArgs = []; - } - }; - - operations.forEach(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( - operation => 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') { - const sharpOperationsForThisInstance = [].concat(operations); - if (options.maxInputPixels) { - sharpOperationsForThisInstance.unshift({ - name: 'limitInputPixels', - args: [options.maxInputPixels] - }); - } - const sharpInstance = sharp(); - const resizeOptions = { - fit: 'inside' // formerly known as .max() - }; - const resizeOperation = operations.find( - operation => operation.name === 'resize' - ); - if (resizeOperation) { - while (resizeOperation.args.length < 2) { - resizeOperation.args.push(undefined); - } - resizeOperation.args.push(resizeOptions); - } - - let converterOptions; - let converterOperation; - for (let i = 0; i < sharpOperationsForThisInstance.length; i += 1) { - const operation = sharpOperationsForThisInstance[i]; - if ( - operation.name === 'progressive' || - operation.name === 'quality' - ) { - // Sharp has deprecated the use of progressive() and quality() in favor of - // passing those options to an explicit conversion, eg. .jpeg({quality: ..., progressive: true}) - let 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 ( - ['withoutEnlargement', 'background'].includes(operation.name) - ) { - // sharp 0.22 removed these, map them to resize options - resizeOptions[operation.name] = operation.args[0]; - sharpOperationsForThisInstance.splice(i, 1); - i -= 1; - } else if (operation.name === 'ignoreAspectRatio') { - // sharp 0.22 removed this, map to the resize option - resizeOptions.fit = 'fill'; - sharpOperationsForThisInstance.splice(i, 1); - i -= 1; - } else if (operation.name === 'crop') { - // sharp 0.22 removed this, map to the resize option - resizeOptions.fit = 'cover'; - resizeOptions.position = operation.args[0]; - 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] - }); - } - } - sharpOperationsForThisInstance.forEach(operation => { - checkSharpOrGmOperation(operation); - let 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') { - const 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: - - const readStream = new Stream(); - readStream.readable = true; - - const readWriteStream = new Stream(); - readWriteStream.readable = readWriteStream.writable = true; - let spawned = false; - readWriteStream.write = chunk => { - if (!spawned) { - spawned = true; - let seenData = false; - let hasEnded = false; - const gmInstance = gm( - readStream, - getMockFileNameForContentType( - gmOperationsForThisInstance[0].sourceContentType || - sourceMetadata.contentType - ) - ); - if (options.maxInputPixels) { - gmInstance.limit('pixels', options.maxInputPixels); - } - let resize; - let crop; - let withoutEnlargement; - let ignoreAspectRatio; - for ( - let i = 0; - i < gmOperationsForThisInstance.length; - i += 1 - ) { - const 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) { - let 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((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((err, stdout, stderr) => { - if (err) { - hasEnded = true; - return readWriteStream.emit('error', err); - } - stdout - .on('data', chunk => { - seenData = true; - readWriteStream.emit('data', chunk); - }) - .once('end', () => { - 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 = chunk => { - if (chunk) { - readWriteStream.write(chunk); - } - readStream.emit('end'); - }; - return readWriteStream; - } else { - throw new Error('Internal error'); - } - } - }); - operations = []; - } - currentEngineName = undefined; - } - - const keyValuePairs = queryString.split('&'); - keyValuePairs.forEach(keyValuePair => { - const matchKeyValuePair = keyValuePair.match(/^([^=]+)(?:=(.*))?/); - if (matchKeyValuePair) { - let operationName = decodeURIComponent(matchKeyValuePair[1]); - // Split by non-URL encoded comma or plus: - let operationArgs = matchKeyValuePair[2] - ? matchKeyValuePair[2].split(/[+,]/).map(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(arg => - arg === '' ? undefined : arg - ); - } - } - - let 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() { - const sourceContentType = this.sourceContentType; - let sharpInstance = sharp(); - const duplexStream = new Stream.Duplex(); - let animatedGifDetector; - let isAnimated; - if (sourceContentType === 'image/gif') { - animatedGifDetector = createAnimatedGifDetector(); - animatedGifDetector.once('animated', function() { - isAnimated = true; - this.emit('decided'); - animatedGifDetector = null; - }); - - duplexStream.once('finish', () => { - if (typeof isAnimated === 'undefined') { - isAnimated = false; - if (animatedGifDetector) { - animatedGifDetector.emit('decided', false); - animatedGifDetector = null; - } - } - }); - } - duplexStream._write = (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: - let metadataCalled = false; - duplexStream._read = 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((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) { - let 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', isAnimated => { - metadata.animated = isAnimated; - proceed(); - }); - } else { - proceed(); - } - }); - }; - duplexStream.once('finish', () => { - 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 - const candidateEngineNames = engineNamesByOperationName[ - operationName - ].filter(engineName => filters[engineName] !== false); - if (candidateEngineNames.length > 0) { - if ( - currentEngineName && - !isOperationByEngineNameAndName[currentEngineName] - ) { - flushOperations(); - } - - if ( - !currentEngineName || - candidateEngineNames.indexOf(currentEngineName) === -1 - ) { - flushOperations(); - if (candidateEngineNames.indexOf(defaultEngineName) !== -1) { - currentEngineName = defaultEngineName; - } else { - currentEngineName = candidateEngineNames[0]; - } - } - const sourceContentType = targetContentType; - let 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, - name: operationName, - args: operationArgs, - usedQueryStringFragment: keyValuePair - }); - usedQueryStringFragments.push(keyValuePair); - } - } else { - const operationNameLowerCase = operationName.toLowerCase(); - - const 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() { - return new FilterConstructor(operationArgs); - }, - operationName: operationNameLowerCase, - usedQueryStringFragments: [keyValuePair] - }; - filterInfos.push(filterInfo); - usedQueryStringFragments.push(keyValuePair); - if (operationNameLowerCase === 'inkscape') { - const filter = filterInfo.create(); - filterInfo.create = () => filter; - targetContentType = `image/${filter.outputFormat}`; - } - } else { - leftOverQueryStringFragments.push(keyValuePair); - } - } - } - } - }); - flushOperations(); - - return { - targetContentType, - operationNames, - filterInfos, - usedQueryStringFragments, - leftOverQueryStringFragments - }; -}; - -module.exports.sharp = sharp; diff --git a/lib/prepareImproQueryString.js b/lib/prepareImproQueryString.js new file mode 100644 index 0000000..1afb831 --- /dev/null +++ b/lib/prepareImproQueryString.js @@ -0,0 +1,96 @@ +const isSupportedEngine = { + gm: true, + sharp: true, + pngcrush: true, + pngquant: true, + jpegtran: true, + optipng: true, + svgfilter: true, + inkscape: true +}; + +const resizeOptions = { + ignoreAspectRatio: true, + withoutEnlargement: true +}; + +const queryStringEngineAndArgsRegex = new RegExp( + `^(${Object.keys(isSupportedEngine).join('|')})=(.*)` +); + +module.exports = function prepareQueryString(queryString) { + const keyValuePairs = queryString.split('&'); + const queryStringFragments = []; + + let hasResize = false; + let optionToResize; + + for (const pair of keyValuePairs) { + let m; + + if ((m = pair.match(queryStringEngineAndArgsRegex)) !== null) { + const [, engineName, engineArgs] = m; + const result = [engineName]; + const remaining = engineArgs.split(','); + + let isEngineOptions = false; + let lastSeenOptionIndex = -1; + let engineOptions; + + for (const [index, bit] of remaining.entries()) { + if (engineName === 'svgfilter' && bit[0] === '-' && bit[1] === '-') { + if (!isEngineOptions) { + isEngineOptions = true; + engineOptions = []; + } + engineOptions.push(bit.slice(2)); + } else if (bit[0] === '-') { + result.push(bit.slice(1)); + lastSeenOptionIndex = index + 1; // account for the engine entry + } else if (engineName === 'pngquant') { + result.push(`speed=${bit}`); + } else if (lastSeenOptionIndex > -1) { + result[lastSeenOptionIndex] += `=${bit}`; + } + } + + if (isEngineOptions) { + result[0] += `=${engineOptions.join('+')}`; + } + + queryStringFragments.push(...result); + } else { + const keyAndValue = pair.split('='); + if (keyAndValue.length === 1) keyAndValue.unshift(''); + const [op, arg] = keyAndValue; + + if (op === 'setFormat') { + let format = arg.toLowerCase(); + if (format === 'jpg') { + format = 'jpeg'; + } + queryStringFragments.push(format); + } else if (arg in resizeOptions && !hasResize) { + optionToResize = arg; + } else { + let fragment = pair; + if (op === 'resize' && arg.indexOf(',') === -1) { + // single value form of resize + fragment += ','; + } + queryStringFragments.push(fragment); + } + + if (op === 'resize') { + if (optionToResize) { + queryStringFragments.push(optionToResize); + optionToResize = undefined; + } else { + hasResize = true; + } + } + } + } + + return queryStringFragments.join('&'); +}; diff --git a/lib/processImage.js b/lib/processImage.js index 8f84f57..9f4ebb4 100644 --- a/lib/processImage.js +++ b/lib/processImage.js @@ -1,13 +1,13 @@ const Path = require('path'); -const _ = require('underscore'); const httpErrors = require('httperrors'); -const getFilterInfosAndTargetContentTypeFromQueryString = require('./getFilterInfosAndTargetContentTypeFromQueryString'); +const impro = require('impro'); const mime = require('mime'); -const stream = require('stream'); const accepts = require('accepts'); const hijackResponse = require('hijackresponse'); -const isImageByExtension = {}; +const prepareImproQueryString = require('./prepareImproQueryString'); + +const isImageByExtension = {}; Object.keys(mime._extensions).forEach(contentType => { if (/^image\//.test(contentType)) { const extension = mime._extensions[contentType]; @@ -21,18 +21,64 @@ function isImageExtension(extension) { return isImageByExtension[extension.toLowerCase()]; } +function pickProperties(obj, properties) { + if (!obj) return {}; + const ret = {}; + for (const property of properties) { + ret[property] = obj[property]; + } + return ret; +} + +function reverseIteratorFor(arr) { + let index = arr.length; + + return { + next: function() { + index -= 1; + + const isEnded = index < 0; + + return { + done: isEnded, + value: !isEnded ? arr[index] : undefined + }; + }, + [Symbol.iterator]() { + return this; + } + }; +} + +function toUsedNames(usedEngines) { + if (usedEngines.length === 0) { + return []; + } else if (usedEngines.length === 1) { + return [usedEngines[0].name]; + } + + let lastUsedName = usedEngines[0].name; + const orderedUsedNames = [lastUsedName]; + // keep the first occurrence of every engine listed as used + usedEngines.forEach(({ name }) => { + if (name !== lastUsedName) { + orderedUsedNames.push(name); + lastUsedName = name; + } + }); + + return orderedUsedNames; +} + module.exports = options => { options = options || {}; - if ( - typeof options.sharpCache !== 'undefined' && - getFilterInfosAndTargetContentTypeFromQueryString.sharp - ) { - getFilterInfosAndTargetContentTypeFromQueryString.sharp.cache( - options.sharpCache - ); - } - return (req, res, next) => { + const engines = pickProperties( + options.filters, + Object.keys(impro.engineByName) + ); + + const middleware = (req, res, next) => { // Polyfill req.accepts for browser-sync compatibility if (typeof req.accepts !== 'function') { req.accepts = function requestAccepts() { @@ -85,20 +131,32 @@ module.exports = options => { } let sourceMetadata; + function makeFilterInfosAndTargetFormat() { - return getFilterInfosAndTargetContentTypeFromQueryString( - queryString, - _.defaults( - { - allowOperation: options.allowOperation, - sourceFilePath: - options.root && - Path.resolve(options.root, req.url.substr(1)), - sourceMetadata - }, - options - ) + const preparedQueryString = prepareImproQueryString(queryString); + const parseResult = impro.parse( + preparedQueryString, + options.allowOperation ); + + // determine the final content type based on the last + // requested type conversion operation (if present) + let outputContentType; + for (const operation of reverseIteratorFor( + parseResult.operations + )) { + if (operation.name === 'metadata') { + outputContentType = 'application/json; charset=utf-8'; + break; + } else if (impro.isTypeByName[operation.name]) { + outputContentType = `image/${operation.name}`; + break; + } + } + parseResult.outputContentType = + outputContentType || sourceMetadata.contentType; + + return parseResult; } const contentLengthHeaderValue = res.getHeader('Content-Length'); @@ -117,54 +175,16 @@ module.exports = options => { function startProcessing(optionalFirstChunk) { let hasEnded = false; - let cleanedUp = false; - let 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(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', () => {}); - }); - filters = null; + + if (!doNotDestroyHijacked) { + res.destroyHijacked(); } + res.removeAllListeners(); } } @@ -173,8 +193,8 @@ module.exports = options => { if (!hasEnded) { hasEnded = true; if (err) { - if ('commandLine' in this) { - err.message = `${this.commandLine}: ${err.message}`; + if ('commandLine' in err) { + err.message = `${err.commandLine}: ${err.message}`; } if ( err.message === @@ -187,6 +207,7 @@ module.exports = options => { err = new httpErrors.RequestEntityTooLarge(err.message); } + res.unpipe(pipeline); next(err); } res.unhijack(); @@ -194,59 +215,84 @@ module.exports = options => { } } - res.once('error', () => { - res.unhijack(); - next(500); + res.once('error', err => { + // trigger teardown of all pipeline streams + pipeline.emit(err); + // respond with an error + handleError(err); }); - res.once('close', cleanUp); - const targetContentType = - filterInfosAndTargetFormat.targetContentType; - if (targetContentType) { - res.setHeader('Content-Type', targetContentType); - } - filters = []; - try { - filterInfosAndTargetFormat.filterInfos.forEach(filterInfo => { - const 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()]; + res.once('close', () => { + cleanUp(); + }); + + const outputContentType = + filterInfosAndTargetFormat.outputContentType; + if (outputContentType) { + res.setHeader('Content-Type', outputContentType); } + + const type = sourceMetadata && sourceMetadata.contentType; + + const pipeline = impro.createPipeline( + { + type, + sourceMetadata, + ...engines, + maxInputPixels: options.maxInputPixels, + maxOutputPixels: options.maxOutputPixels, + sharpCache: options.sharpCache, + svgAssetPath: options.root + ? Path.resolve(options.root, req.url.substr(1)) + : null + }, + filterInfosAndTargetFormat.operations + ); + if (options.debug) { + let usedEngines; + try { + usedEngines = pipeline.flush().usedEngines; + } catch (e) { + res.unhijack(); + return next(e); + } + // 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(filterInfo => filterInfo.operationName) - .join(',') + toUsedNames(usedEngines).join(',') ); } - if (optionalFirstChunk) { - filters[0].write(optionalFirstChunk); + + if (typeof options.onPipeline === 'function') { + options.onPipeline(pipeline); } - for (let 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 (optionalFirstChunk) { + pipeline.write(optionalFirstChunk); } - res.pipe(filters[0]); - filters[filters.length - 1] + // send along processed data + pipeline + .on('error', handleError) .on('end', () => { - hasEnded = true; - cleanUp(); + if (!hasEnded) { + hasEnded = true; + cleanUp(); + res.end(); + } }) - .pipe(res); + .on('readable', function() { + if (!hasEnded) { + let data; + while ((data = this.read())) { + res.write(data); + } + } + }); + + // initiate processing + res.pipe(pipeline); } const contentType = res.getHeader('Content-Type'); @@ -272,9 +318,10 @@ module.exports = options => { filterInfosAndTargetFormat = makeFilterInfosAndTargetFormat(); - if (filterInfosAndTargetFormat.filterInfos.length === 0) { + if (filterInfosAndTargetFormat.operations.length === 0) { return res.unhijack(true); } + if (options.secondGuessSourceContentType) { const endOrCloseOrErrorBeforeFirstDataChunkListener = err => { if (err) { @@ -318,7 +365,6 @@ module.exports = options => { detectedContentType !== sourceMetadata.contentType ) { sourceMetadata.contentType = detectedContentType; - filterInfosAndTargetFormat = makeFilterInfosAndTargetFormat(); } startProcessing(firstChunk); }); @@ -336,4 +382,9 @@ module.exports = options => { next(); } }; + + // exposed for some testing scenarios + middleware._impro = impro; + + return middleware; }; diff --git a/package.json b/package.json index 15ca1ff..e47108f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "hijackresponse": "^4.0.0", "httperrors": "^2.0.1", "icc": "^1.0.0", + "impro": "~0.5.0", "inkscape": "^2.0.0", "jpegtran": "^1.0.6", "mime": "^2.3.1", @@ -25,8 +26,7 @@ "passerror": "^1.1.1", "pngcrush": "^2.0.1", "pngquant": "^3.0.0", - "sharp": "^0.23.0", - "underscore": "^1.8.3" + "sharp": "^0.23.0" }, "devDependencies": { "browser-sync": "^2.18.6", diff --git a/test/getFilterInfosAndTargetContentTypeFromQueryString.js b/test/getFilterInfosAndTargetContentTypeFromQueryString.js deleted file mode 100644 index 20cddac..0000000 --- a/test/getFilterInfosAndTargetContentTypeFromQueryString.js +++ /dev/null @@ -1,196 +0,0 @@ -const getFilterInfosAndTargetContentTypeFromQueryString = require('../lib/getFilterInfosAndTargetContentTypeFromQueryString'); -const expect = require('unexpected'); - -describe('getFilterInfosAndTargetContentTypeFromQueryString', () => { - it('should make the right engine choice even if the source Content-Type is not available until filterInfo.create is called', () => { - const 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', () => { - it('should match #rrggbb', () => { - const 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', () => { - const 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', () => { - const 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', () => { - const filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( - 'background=#0000' - ); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - filterInfos: [ - { - operations: [ - { - name: 'background', - usedQueryStringFragment: 'background=#0000' - } - ], - leftOverQueryStringFragments: undefined - } - ] - }); - }); - }); - - describe('sharp', () => { - it('should allow using setFormat to specify the output format', () => { - const 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', () => { - it('should fall back to another engine', () => { - const 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' - } - ] - }); - }); - - it('should fall back to gm if there is an unsupported operation', () => { - const filterInfosAndTargetContentTypeFromQueryString = getFilterInfosAndTargetContentTypeFromQueryString( - 'setFormat=gif&embed', - { - defaultEngineName: 'sharp', - sourceMetadata: { - contentType: 'image/jpeg' - } - } - ); - - filterInfosAndTargetContentTypeFromQueryString.filterInfos[0].create(); - - expect(filterInfosAndTargetContentTypeFromQueryString, 'to satisfy', { - targetContentType: 'image/gif', - operationNames: ['gm', 'sharpOrGm'], - filterInfos: [ - { - operationName: 'gm' - }, - { - operationName: 'sharpOrGm' - } - ] - }); - }); - }); - }); -}); diff --git a/test/prepareImproQueryString.js b/test/prepareImproQueryString.js new file mode 100644 index 0000000..64b0cda --- /dev/null +++ b/test/prepareImproQueryString.js @@ -0,0 +1,87 @@ +const expect = require('unexpected').clone(); + +const prepareImproQueryString = require('../lib/prepareImproQueryString'); + +expect.addAssertion(' when prepared ', (expect, subject) => { + expect.errorMode = 'nested'; + + return expect.shift(prepareImproQueryString(subject)); +}); + +describe('prepareImproQueryString', () => { + it('should parse svgfilter with options', () => { + expect( + 'svgfilter=--runScript=addBogusElement.js,--bogusElementId=theBogusElementId', + 'when prepared to equal', + 'svgfilter=runScript=addBogusElement.js+bogusElementId=theBogusElementId' + ); + }); + + it('should parse setFormat and other arguments', () => { + expect( + 'setFormat=JPG&resize=800,800', + 'when prepared to equal', + 'jpeg&resize=800,800' + ); + }); + + it('should parse ignoreAspectRatio followed by resize', () => { + expect( + 'ignoreAspectRatio&resize=800,800', + 'when prepared to equal', + 'resize=800,800&ignoreAspectRatio' + ); + }); + + it('should parse withoutEnlargement followed by resize', () => { + expect( + 'withoutEnlargement&resize=800,800', + 'when prepared to equal', + 'resize=800,800&withoutEnlargement' + ); + }); + + it('should parse resize followed by withoutEnlargement', () => { + expect( + 'resize=800,800&withoutEnlargement', + 'when prepared to equal', + 'resize=800,800&withoutEnlargement' + ); + }); + + it('should parse jpegtran and an argument with -flip', () => { + expect( + 'jpegtran=-grayscale,-flip,horizontal', + 'when prepared to equal', + 'jpegtran&grayscale&flip=horizontal' + ); + }); + + it('should parse pngquant with integer argument correctly', () => { + expect( + 'resize=800,800&pngquant=8', + 'when prepared to equal', + 'resize=800,800&pngquant&speed=8' + ); + }); + + it('should parse pngcrush with integer argument correctly', () => { + expect( + 'resize=800,800&pngcrush=-rem,gAMA', + 'when prepared to equal', + 'resize=800,800&pngcrush&rem=gAMA' + ); + }); + + it('should parse multiple engines and their operations', () => { + expect( + 'resize=800,800&pngquant=8&pngcrush=-rem,gAMA', + 'when prepared to equal', + 'resize=800,800&pngquant&speed=8&pngcrush&rem=gAMA' + ); + }); + + it('should parse the single format form of resize', () => { + expect('resize=800', 'when prepared to equal', 'resize=800,'); + }); +}); diff --git a/test/processImage.js b/test/processImage.js index 75999bb..46a4b3a 100644 --- a/test/processImage.js +++ b/test/processImage.js @@ -4,20 +4,25 @@ const http = require('http'); const pathModule = require('path'); const unexpected = require('unexpected'); const sinon = require('sinon'); -const Stream = require('stream'); const processImage = require('../lib/processImage'); const root = `${pathModule.resolve(__dirname, '..', 'testdata')}/`; const sharp = require('sharp'); describe('express-processimage', () => { let config; + let impro; let sandbox; + beforeEach(() => { config = { root, filters: {} }; sandbox = sinon.createSandbox(); }); afterEach(() => { + if (impro) { + // clear the cache marker to ensure clean state for each test + delete impro._sharpCacheSet; + } sandbox.restore(); }); @@ -31,17 +36,22 @@ describe('express-processimage', () => { .use(require('magicpen-prism')) .addAssertion( ' to yield response ', - (expect, subject, value) => - expect( + (expect, subject, value) => { + const middleware = processImage(config); + + impro = middleware._impro; + + return expect( express() - .use(processImage(config)) + .use(middleware) .use(express.static(root)), 'to yield exchange', { request: subject, response: value } - ) + ); + } ) .addAssertion( ' [when] converted to PNG ', @@ -221,9 +231,9 @@ describe('express-processimage', () => { }) } ).then(() => - expect(console.error, 'to have no calls satisfying', () => - console.error(/DeprecationWarning/) - ) + expect(console.error, 'to have no calls satisfying', [ + /DeprecationWarning/ + ]) ); }); @@ -250,15 +260,15 @@ describe('express-processimage', () => { }) } ).then(() => - expect(console.error, 'to have no calls satisfying', () => - console.error(/DeprecationWarning/) - ) + expect(console.error, 'to have no calls satisfying', [ + /DeprecationWarning/ + ]) ); }); }); - it('should resize by specifying a bounding box', () => - expect('GET /turtle.jpg?resize=500,1000', 'to yield response', { + it('should resize by specifying a bounding box (gm)', () => + expect('GET /turtle.jpg?gm&resize=500,1000', 'to yield response', { body: expect.it('to have metadata satisfying', { size: { width: 500, @@ -295,9 +305,7 @@ describe('express-processimage', () => { contentType: 'image/jpeg' } }).then(() => { - expect(cacheStub, 'to have calls satisfying', () => { - cacheStub(123); - }); + expect(cacheStub, 'to have a call satisfying', [123]); }); }); @@ -551,19 +559,19 @@ describe('express-processimage', () => { }) })); - it('should use sharp when a gif is converted to png', () => { + it('should use the best engine for an operation for a GIF', () => { config.debug = true; return expect( 'GET /animated.gif?resize=40,100&png', 'to yield response', { headers: { - 'X-Express-Processimage': 'sharp' + 'X-Express-Processimage': 'gifsicle,sharp' }, body: expect.it('to have metadata satisfying', { format: 'PNG', size: { - width: 40 + width: 23 } }) } @@ -881,9 +889,10 @@ describe('express-processimage', () => { }, body: expect.it('to have metadata satisfying', { size: { width: 87 } }) }).then(() => { - expect(config.allowOperation, 'to have calls satisfying', () => { - config.allowOperation('resize', [87, 100]); - }); + expect(config.allowOperation, 'to have a call satisfying', [ + 'resize', + [87, 100] + ]); })); it('should disallow an operation for which allowOperation returns false', () => @@ -895,9 +904,7 @@ describe('express-processimage', () => { format: 'JPEG' }) }).then(() => { - expect(config.allowOperation, 'to have calls satisfying', () => { - config.allowOperation('png', []); - }); + expect(config.allowOperation, 'to have a call satisfying', ['png', []]); })); }); @@ -1326,63 +1333,56 @@ describe('express-processimage', () => { }); describe('against a real server', () => { - it('should destroy the created filters when the client closes the connection prematurely', () => { - let server; - const createdStreams = []; - let request; - return expect - .promise(run => { - config.filters = { - montage: run(() => ({ - create: run(() => { - const stream = new Stream.Transform(); - stream._transform = (chunk, encoding, callback) => { - setTimeout(() => { - callback(null, chunk); - }, 1000); - }; - stream.destroy = sandbox.spy().named('destroy'); - createdStreams.push(stream); - setTimeout( - run(() => { - request.abort(); - }), - 0 - ); - return stream; - }) - })) - }; - server = express() - .use(processImage(config)) - .use(express.static(root)) - .listen(0); - - const serverAddress = server.address(); - const serverHostname = - serverAddress.address === '::' - ? 'localhost' - : serverAddress.address; - const serverUrl = `http://${serverHostname}:${serverAddress.port}/testImage.png?montage`; - - request = http.get(serverUrl); - request.end(); - request.once( - 'error', - run(err => { + it('should destroy the created filters when the client closes the connection prematurely', async () => { + let resolveOnPipeline; + const onPipelinePromise = new Promise( + resolve => (resolveOnPipeline = resolve) + ); + const config = { + onPipeline: p => { + resolveOnPipeline(p); + } + }; + + const server = express() + .use(processImage(config)) + .use(express.static(root)) + .listen(0); + const serverAddress = server.address(); + const serverHostname = + serverAddress.address === '::' ? 'localhost' : serverAddress.address; + const serverUrl = `http://${serverHostname}:${serverAddress.port}/testImage.png?progressive`; + const request = http.get(serverUrl); + request.end(); + + const spies = []; + + try { + const pipeline = await onPipelinePromise; + + spies.push(sinon.spy(pipeline._streams[0], 'unpipe')); + + setTimeout(() => { + request.abort(); + }, 0); + + await new Promise((resolve, reject) => { + request.once('error', err => { + try { expect(err, 'to have message', 'socket hang up'); - }) - ); - }) - .then(() => { - return new Promise(resolve => setTimeout(resolve, 10)); - }) - .then(() => { - expect(createdStreams[0].destroy, 'was called once'); - }) - .finally(() => { - server.close(); + resolve(); + } catch (e) { + reject(e); + } + }); }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(spies[0], 'was called once'); + } finally { + server.close(); + } }); });