Skip to content

Commit

Permalink
Add support for the operations exposed by the 'sharp' library. Fixes #6.
Browse files Browse the repository at this point in the history
NOTE: The test doesn't work yet, but it seems to be working fine when just getFilterInfosAndTargetContentTypeFromQueryString from an external app.
  • Loading branch information
papandreou committed Oct 22, 2014
1 parent 236225d commit b624be0
Show file tree
Hide file tree
Showing 3 changed files with 143 additions and 73 deletions.
198 changes: 127 additions & 71 deletions lib/getFilterInfosAndTargetContentTypeFromQueryString.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
var Stream = require('stream'),
gm = require('gm'),
isGmOperationByName = {},
sharp = require('sharp'),
isOperationByEngineNameAndName = {gm: {}, sharp: {}},
filterConstructorByOperationName = {};

['PngQuant', 'PngCrush', 'OptiPng', 'JpegTran', 'Inkscape', 'SvgFilter'].forEach(function (constructorName) {
Expand All @@ -13,84 +14,115 @@ var Stream = require('stream'),
});

Object.keys(gm.prototype).forEach(function (propertyName) {
if (!/^_|^(?:size|orientation|format|depth|color|res|filesize|identity|write|stream)$/.test(propertyName) &&
if (!/^_|^(?:emit|.*Listeners?|on|once|size|orientation|format|depth|color|res|filesize|identity|write|stream)$/.test(propertyName) &&
typeof gm.prototype[propertyName] === 'function') {
isGmOperationByName[propertyName] = true;
isOperationByEngineNameAndName.gm[propertyName] = true;
}
});

['resize', 'extract', 'sequentialRead', 'crop', 'max', 'background', 'embed', 'flatten', 'rotate', 'flip', 'flop', 'withoutEnlargement', 'sharpen', 'interpolateWith', 'gamma', 'grayscale', 'greyscale', 'jpeg', 'webp', 'quality', 'progressive', 'withMetadata', 'compressionLevel'].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);
});
});

module.exports = function getFilterInfosAndTargetContentTypeFromQueryString(queryString, options) {
options = options || {};
var filters = options.filters || {},
filterInfos = [],
gmOperations = [],
defaultEngineName = options.defaultEngineName || 'gm',
currentEngineName,
operations = [],
operationNames = [],
usedQueryStringFragments = [],
leftOverQueryStringFragments = [],
targetContentType;

function flushGmOperations() {
if (gmOperations.length > 0) {
var gmOperationsForThisInstance = [].concat(gmOperations);
operationNames.push('gm');
filterInfos.push({
operationName: 'gm',
usedQueryStringFragments: gmOperations.map(function (gmOperation) {
return gmOperation.usedQueryStringFragment;
}),
create: function () {
// For some reason the gm module doesn't expose itself as a readable/writable stream,
// so we need to wrap it into one:
function flushOperations() {
if (operations.length > 0) {
if (currentEngineName === 'sharp') {
var sharpOperationsForThisInstance = [].concat(operations);
operationNames.push('sharp');
filterInfos.push({
operationName: 'sharp',
usedQueryStringFragments: operations.map(function (operation) {
return operation.usedQueryStringFragment;
}),
create: function () {
return sharpOperationsForThisInstance.reduce(function (sharpInstance, sharpOperation) {
return sharpInstance[sharpOperation.name].apply(sharpInstance, sharpOperation.args)
}, sharp());
}
});
} else if (currentEngineName === 'gm') {
var gmOperationsForThisInstance = [].concat(operations);
operationNames.push('gm');
filterInfos.push({
operationName: 'gm',
usedQueryStringFragments: operations.map(function (gmOperation) {
return gmOperation.usedQueryStringFragment;
}),
create: function () {
// 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 readStream = new Stream();
readStream.readable = true;

var readWriteStream = new Stream();
readWriteStream.readable = readWriteStream.writable = true;
var spawned = false;
var readWriteStream = new Stream();
readWriteStream.readable = readWriteStream.writable = true;
var spawned = false;

readWriteStream.write = function (chunk) {
if (!spawned) {
spawned = true;
var gmInstance = gm(readStream);
gmOperationsForThisInstance.forEach(function (gmOperation) {
gmInstance = gmInstance[gmOperation.name].apply(gmInstance, gmOperation.args);
});
var seenData = false,
hasEnded = false;
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);
}).on('end', function () {
if (!hasEnded) {
if (seenData) {
readWriteStream.emit('end');
} else {
readWriteStream.emit('error', new Error('The gm stream ended without emitting any data'));
}
readWriteStream.write = function (chunk) {
if (!spawned) {
spawned = true;
var seenData = false,
hasEnded = false;
gmOperationsForThisInstance.reduce(function (gmInstance, gmOperation) {
return gmInstance[gmOperation.name].apply(gmInstance, gmOperation.args);
}, gm(readStream)).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);
}).on('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;
}
});
gmOperations = [];
}
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;
}

queryString.split('&').forEach(function (keyValuePair) {
Expand All @@ -112,7 +144,7 @@ module.exports = function getFilterInfosAndTargetContentTypeFromQueryString(quer
}) : [];

if (filters[operationName]) {
flushGmOperations();
flushOperations();
var filterInfo = filters[operationName](operationArgs, {
inputContentType: targetContentType,
numPreceedingFilters: filterInfos.length
Expand All @@ -124,27 +156,51 @@ module.exports = function getFilterInfosAndTargetContentTypeFromQueryString(quer
targetContentType = filterInfo.outputContentType;
}
filterInfos.push(filterInfo);
operationNames.push(operationNameLowerCase);
operationNames.push(operationName);
usedQueryStringFragments.push(keyValuePair);
} else {
leftOverQueryStringFragments.push(keyValuePair);
}
} else if (isGmOperationByName[operationName] && filters.gm !== false) {
if (operationName === 'setFormat' && operationArgs.length > 0) {
var targetFormat = operationArgs[0].toLowerCase();
if (targetFormat === 'jpg') {
targetFormat = 'jpeg';
} 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();
}
targetContentType = 'image/' + targetFormat;

if (!currentEngineName || candidateEngineNames.indexOf(currentEngineName) === -1) {
if (candidateEngineNames.indexOf(defaultEngineName) !== -1) {
currentEngineName = defaultEngineName;
} else {
currentEngineName = candidateEngineNames[0];
}
}

if (operationName === 'setFormat' && operationArgs.length > 0) {
var targetFormat = operationArgs[0].toLowerCase();
if (targetFormat === 'jpg') {
targetFormat = 'jpeg';
}
targetContentType = 'image/' + targetFormat;
} else if (operationName === 'jpeg' || operationName === 'png' || operationName === 'webp') {
targetContentType = 'image/' + operationName;
}
operations.push({name: operationName, args: operationArgs, usedQueryStringFragment: keyValuePair});
usedQueryStringFragments.push(keyValuePair);
}
gmOperations.push({name: operationName, args: operationArgs, usedQueryStringFragment: keyValuePair});
usedQueryStringFragments.push(keyValuePair);
} else {
var operationNameLowerCase = operationName.toLowerCase(),
FilterConstructor = filterConstructorByOperationName[operationNameLowerCase];
if (FilterConstructor && filters[operationNameLowerCase] !== false) {
operationNames.push(operationNameLowerCase);
flushGmOperations();
flushOperations();
if (operationNameLowerCase === 'svgfilter' && options.rootPath && options.sourceFilePath) {
operationArgs.push('--root', 'file://' + options.rootPath, '--url', 'file://' + options.sourceFilePath);
}
Expand All @@ -170,7 +226,7 @@ module.exports = function getFilterInfosAndTargetContentTypeFromQueryString(quer
}
}
});
flushGmOperations();
flushOperations();

return {
targetContentType: targetContentType,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"optipng": "0.1.1",
"passerror": "0.0.1",
"pngcrush": "0.1.0",
"pngquant": "0.3.0"
"pngquant": "0.3.0",
"sharp": "0.7.0"
},
"optionalDependencies": {
"svgfilter": "0.4.0"
Expand Down
15 changes: 14 additions & 1 deletion test/processImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe('test server', function () {
});

it('should run the image through graphicsmagick when methods exposed by the gm module are added as CGI params', function (done) {
request({url: baseUrl + '/turtle.jpg?resize=340,300', encoding: null}, passError(done, function (response, body) {
request({url: baseUrl + '/turtle.jpg?gm&resize=340,300', encoding: null}, passError(done, function (response, body) {
expect(response.statusCode, 'to equal', 200);
expect(response.headers['content-type'], 'to equal', 'image/jpeg');
expect(body.slice(0, 10).toString(), 'to equal', new Buffer([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]).toString());
Expand All @@ -128,6 +128,19 @@ describe('test server', function () {
}));
});

it.skip('should run the image through sharp when methods exposed by the sharp module are added as CGI params', function (done) {
request({url: baseUrl + '/turtle.jpg?sharp&resize=340,300', encoding: null}, passError(done, function (response, body) {
expect(response.statusCode, 'to equal', 200);
expect(response.headers['content-type'], 'to equal', 'image/jpeg');
getImageMetadataFromBuffer(body, passError(done, function (metadata) {
expect(metadata.format, 'to equal', 'JPEG');
expect(metadata.size.width, 'to equal', 340);
expect(metadata.size.height, 'to equal', 300);
done();
}));
}));
});

it('should run the image through svgfilter when the svgfilter parameter is specified', function (done) {
request({url: baseUrl + '/dialog-information.svg?svgfilter=--runScript=addBogusElement.js,--bogusElementId=theBogusElementId'}, passError(done, function (response, svgText) {
expect(response.statusCode, 'to equal', 200);
Expand Down

6 comments on commit b624be0

@Munter
Copy link
Collaborator

@Munter Munter commented on b624be0 Oct 22, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty awesome. Does this also incidentally remove the gm requirement on resizing images?

@papandreou
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could. Both modules expose resize with the same signature. My intention is to make sharp the default engine once I have that test passing. Then the graphicsmagick binary would only be required for the operations that aren't supported by sharp.

@lovell
Copy link

@lovell lovell commented on b624be0 Oct 22, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, sharp author here, thanks for using it with your express-processimage module.

"the operations that aren't supported by sharp"

Please feel free to open issues (or +1 existing issues) for any additional features you need.

I'm currently working on lovell/sharp#42 which should remove the overhead of manually installing libvips.

@papandreou
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lovell Thanks for getting in touch! Really liking your library. I sent you a couple of PRs for the very minor issues I found.

Good to hear that you're working on lovell/sharp#42. That one gets my vote. It would be a great win not to have any non-npm prerequisites.

@Munter Yeah, that means we're not quite there yet wrt. resize without having to install separate libraries. It's either graphicsmagick or libvips. I guess I should make sharp an optional dependency until we can deliver that.

@Munter
Copy link
Collaborator

@Munter Munter commented on b624be0 Oct 23, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have experience with making things optional, so that's fine.

@lovell I am very happy to hear that you are putting in time on trying to remove the non-npm dependencies. This module is used in the larger contexts of assetgraph-builder and livestyle, which both require some time investment by the user in order to install the external dependencies. Any one of these we can remove means a better user impression and higher adoption :)

@lovell
Copy link

@lovell lovell commented on b624be0 Oct 23, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tak!

Please sign in to comment.