diff --git a/docs/api.md b/docs/api.md index 97b29b30..688e5260 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,15 +12,16 @@ Constructor to which further methods are chained. `input`, if present, can be one of: -* Buffer containing JPEG, PNG, WebP, GIF, SVG or TIFF image data, or +* Buffer containing JPEG, PNG, WebP, GIF, SVG, TIFF or raw pixel image data, or * String containing the path to an image file, with most major formats supported. -JPEG, PNG, WebP, GIF, SVG or TIFF format image data +JPEG, PNG, WebP, GIF, SVG, TIFF or raw pixel image data can be streamed into the object when `input` is `null` or `undefined`. `options`, if present, is an Object with the following optional attributes: * `density` an integral number representing the DPI for vector images, defaulting to 72. +* `raw` an Object containing `width`, `height` and `channels` when providing uncompressed data. See `raw()` for pixel ordering. The object returned by the constructor implements the [stream.Duplex](http://nodejs.org/api/stream.html#stream_class_stream_duplex) class. @@ -400,7 +401,7 @@ sharp('input.png') `callback`, if present, is called with two arguments `(err, info)` where: * `err` contains an error message, if any. -* `info` contains the output image `format`, `size` (bytes), `width` and `height`. +* `info` contains the output image `format`, `size` (bytes), `width`, `height` and `channels`. A Promises/A+ promise is returned when `callback` is not provided. @@ -412,7 +413,7 @@ Write image data to a Buffer, the format of which will match the input image by * `err` is an error message, if any. * `buffer` is the output image data. -* `info` contains the output image `format`, `size` (bytes), `width` and `height`. +* `info` contains the output image `format`, `size` (bytes), `width`, `height` and `channels`. A Promises/A+ promise is returned when `callback` is not provided. diff --git a/docs/changelog.md b/docs/changelog.md index 64e5a5a0..0cb57a2d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,10 @@ [#110](https://github.com/lovell/sharp/issues/110) [@bradisbell](https://github.com/bradisbell) +* Add support for raw, uncompressed pixel Buffer/Stream input. + [#220](https://github.com/lovell/sharp/issues/220) + [@mikemorris](https://github.com/mikemorris) + * Switch from libvips' C to C++ bindings, requires upgrade to v8.2.2. [#299](https://github.com/lovell/sharp/issues/299) diff --git a/index.js b/index.js index 5e11cb2c..f7179503 100644 --- a/index.js +++ b/index.js @@ -47,6 +47,9 @@ var Sharp = function(input, options) { sequentialRead: false, limitInputPixels: maximum.pixels, density: '72', + rawWidth: 0, + rawHeight: 0, + rawChannels: 0, // ICC profiles iccProfilePath: path.join(__dirname, 'icc') + path.sep, // resize options @@ -135,23 +138,52 @@ module.exports.format = sharp.format(); */ module.exports.versions = versions; +/* + Validation helpers +*/ +var isDefined = function(val) { + return typeof val !== 'undefined' && val !== null; +}; +var isObject = function(val) { + return typeof val === 'object'; +}; +var isInteger = function(val) { + return typeof val === 'number' && !Number.isNaN(val) && val % 1 === 0; +}; +var inRange = function(val, min, max) { + return val >= min && val <= max; +}; + /* Set input-related options density: DPI at which to load vector images via libmagick */ Sharp.prototype._inputOptions = function(options) { - if (typeof options === 'object') { - if (typeof options.density !== 'undefined') { - if ( - typeof options.density === 'number' && !Number.isNaN(options.density) && - options.density % 1 === 0 && options.density > 0 && options.density <= 2400 - ) { + if (isObject(options)) { + // Density + if (isDefined(options.density)) { + if (isInteger(options.density) && inRange(options.density, 1, 2400)) { this.options.density = options.density.toString(); } else { - throw new Error('Invalid density (1 to 2400)' + options.density); + throw new Error('Invalid density (1 to 2400) ' + options.density); } } - } else if (typeof options !== 'undefined' && options !== null) { + // Raw pixel input + if (isDefined(options.raw)) { + if ( + isObject(options.raw) && + isInteger(options.raw.width) && inRange(options.raw.width, 1, maximum.width) && + isInteger(options.raw.height) && inRange(options.raw.height, 1, maximum.height) && + isInteger(options.raw.channels) && inRange(options.raw.channels, 1, 4) + ) { + this.options.rawWidth = options.raw.width; + this.options.rawHeight = options.raw.height; + this.options.rawChannels = options.raw.channels; + } else { + throw new Error('Expected width, height and channels for raw pixel input'); + } + } + } else if (isDefined(options)) { throw new Error('Invalid input options ' + options); } }; diff --git a/src/common.h b/src/common.h index 276567ac..c2adf5e8 100644 --- a/src/common.h +++ b/src/common.h @@ -17,7 +17,8 @@ namespace sharp { MAGICK, OPENSLIDE, PPM, - FITS + FITS, + RAW }; // How many tasks are in the queue? diff --git a/src/metadata.cc b/src/metadata.cc index a19877ae..1d781315 100644 --- a/src/metadata.cc +++ b/src/metadata.cc @@ -122,6 +122,7 @@ class MetadataWorker : public AsyncWorker { case ImageType::OPENSLIDE: baton->format = "openslide"; break; case ImageType::PPM: baton->format = "ppm"; break; case ImageType::FITS: baton->format = "fits"; break; + case ImageType::RAW: baton->format = "raw"; break; case ImageType::UNKNOWN: break; } // VipsImage attributes diff --git a/src/pipeline.cc b/src/pipeline.cc index c032c2a8..8efd1154 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -78,6 +78,9 @@ struct PipelineBaton { std::string iccProfilePath; int limitInputPixels; std::string density; + int rawWidth; + int rawHeight; + int rawChannels; std::string output; std::string outputFormat; void *bufferOut; @@ -92,6 +95,7 @@ struct PipelineBaton { int heightPost; int width; int height; + int channels; Canvas canvas; int gravity; std::string interpolator; @@ -131,10 +135,14 @@ struct PipelineBaton { bufferInLength(0), limitInputPixels(0), density(""), + rawWidth(0), + rawHeight(0), + rawChannels(0), outputFormat(""), bufferOutLength(0), topOffsetPre(-1), topOffsetPost(-1), + channels(0), canvas(Canvas::CROP), gravity(0), flatten(false), @@ -199,20 +207,33 @@ class PipelineWorker : public AsyncWorker { VImage image; if (baton->bufferInLength > 0) { // From buffer - inputImageType = DetermineImageType(baton->bufferIn, baton->bufferInLength); - if (inputImageType != ImageType::UNKNOWN) { - try { - image = VImage::new_from_buffer( - baton->bufferIn, baton->bufferInLength, nullptr, VImage::option() - ->set("access", baton->accessMethod) - ->set("density", baton->density.data()) - ); - } catch (...) { - (baton->err).append("Input buffer has corrupt header"); - inputImageType = ImageType::UNKNOWN; + if (baton->rawWidth > 0 && baton->rawHeight > 0 && baton->rawChannels > 0) { + // Raw, uncompressed pixel data + image = VImage::new_from_memory(baton->bufferIn, baton->bufferInLength, + baton->rawWidth, baton->rawHeight, baton->rawChannels, VIPS_FORMAT_UCHAR); + if (baton->rawChannels < 3) { + image.get_image()->Type = VIPS_INTERPRETATION_B_W; + } else { + image.get_image()->Type = VIPS_INTERPRETATION_sRGB; } + inputImageType = ImageType::RAW; } else { - (baton->err).append("Input buffer contains unsupported image format"); + // Compressed data + inputImageType = DetermineImageType(baton->bufferIn, baton->bufferInLength); + if (inputImageType != ImageType::UNKNOWN) { + try { + image = VImage::new_from_buffer( + baton->bufferIn, baton->bufferInLength, nullptr, VImage::option() + ->set("access", baton->accessMethod) + ->set("density", baton->density.data()) + ); + } catch (...) { + (baton->err).append("Input buffer has corrupt header"); + inputImageType = ImageType::UNKNOWN; + } + } else { + (baton->err).append("Input buffer contains unsupported image format"); + } } } else { // From file @@ -667,12 +688,9 @@ class PipelineWorker : public AsyncWorker { // Convert image to sRGB, if not already if (image.interpretation() == VIPS_INTERPRETATION_RGB16) { - // Cast to integer and fast 8-bit conversion by discarding LSB - image = image.cast(VIPS_FORMAT_USHORT).msb(); - // Explicitly set interpretation to sRGB - image.get_image()->Type = VIPS_INTERPRETATION_sRGB; - } else if (image.interpretation() != VIPS_INTERPRETATION_sRGB) { - // Switch interpretation to sRGB + image = image.cast(VIPS_FORMAT_USHORT); + } + if (image.interpretation() != VIPS_INTERPRETATION_sRGB) { image = image.colourspace(VIPS_INTERPRETATION_sRGB); // Transform colours from embedded profile to sRGB profile if (baton->withMetadata && HasProfile(image)) { @@ -791,6 +809,8 @@ class PipelineWorker : public AsyncWorker { return Error(); } } + // Number of channels used in output image + baton->channels = image.bands(); } catch (VError const &err) { (baton->err).append(err.what()); } @@ -822,6 +842,7 @@ class PipelineWorker : public AsyncWorker { Set(info, New("format").ToLocalChecked(), New(baton->outputFormat).ToLocalChecked()); Set(info, New("width").ToLocalChecked(), New(static_cast(width))); Set(info, New("height").ToLocalChecked(), New(static_cast(height))); + Set(info, New("channels").ToLocalChecked(), New(static_cast(baton->channels))); if (baton->bufferOutLength > 0) { // Pass ownership of output data to Buffer instance @@ -1003,6 +1024,10 @@ NAN_METHOD(pipeline) { baton->limitInputPixels = attrAs(options, "limitInputPixels"); // Density/DPI at which to load vector images via libmagick baton->density = attrAsStr(options, "density"); + // Raw pixel input + baton->rawWidth = attrAs(options, "rawWidth"); + baton->rawHeight = attrAs(options, "rawHeight"); + baton->rawChannels = attrAs(options, "rawChannels"); // Extract image options baton->topOffsetPre = attrAs(options, "topOffsetPre"); baton->leftOffsetPre = attrAs(options, "leftOffsetPre"); diff --git a/test/unit/io.js b/test/unit/io.js index 20dfd241..ddc33c88 100644 --- a/test/unit/io.js +++ b/test/unit/io.js @@ -862,6 +862,88 @@ describe('Input/output', function() { }); }); + describe('Raw pixel input', function() { + it('Missing options', function() { + assert.throws(function() { + sharp(null, { raw: {} } ); + }); + }); + it('Incomplete options', function() { + assert.throws(function() { + sharp(null, { raw: { width: 1, height: 1} } ); + }); + }); + it('Invalid channels', function() { + assert.throws(function() { + sharp(null, { raw: { width: 1, height: 1, channels: 5} } ); + }); + }); + it('Invalid height', function() { + assert.throws(function() { + sharp(null, { raw: { width: 1, height: 0, channels: 4} } ); + }); + }); + it('Invalid width', function() { + assert.throws(function() { + sharp(null, { raw: { width: 'zoinks', height: 1, channels: 4} } ); + }); + }); + it('RGB', function(done) { + // Convert to raw pixel data + sharp(fixtures.inputJpg) + .resize(256) + .raw() + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual(256, info.width); + assert.strictEqual(209, info.height); + assert.strictEqual(3, info.channels); + // Convert back to JPEG + sharp(data, { + raw: { + width: info.width, + height: info.height, + channels: info.channels + }}) + .jpeg() + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual(256, info.width); + assert.strictEqual(209, info.height); + assert.strictEqual(3, info.channels); + fixtures.assertSimilar(fixtures.inputJpg, data, done); + }); + }); + }); + it('RGBA', function(done) { + // Convert to raw pixel data + sharp(fixtures.inputPngOverlayLayer1) + .resize(256) + .raw() + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual(256, info.width); + assert.strictEqual(192, info.height); + assert.strictEqual(4, info.channels); + // Convert back to PNG + sharp(data, { + raw: { + width: info.width, + height: info.height, + channels: info.channels + }}) + .png() + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual(256, info.width); + assert.strictEqual(192, info.height); + assert.strictEqual(4, info.channels); + fixtures.assertSimilar(fixtures.inputPngOverlayLayer1, data, done); + }); + }); + }); + }); + it('Queue length change events', function(done) { var eventCounter = 0; var queueListener = function(queueLength) {