diff --git a/History.md b/History.md index 3e2060ac7..1dcf3a0a0 100644 --- a/History.md +++ b/History.md @@ -2,6 +2,9 @@ Unreleased / patch ================== * Port has_lib.sh to javascript (#872) + * Support canvas.getContext("2d", {alpha: boolean}) and + canvas.getContext("2d", {pixelFormat: "..."}) + * Support indexed PNG encoding. 1.6.0 / 2016-10-16 ================== diff --git a/Readme.md b/Readme.md index 075b1d46f..8a91014ef 100644 --- a/Readme.md +++ b/Readme.md @@ -117,7 +117,7 @@ img.dataMode = Image.MODE_MIME | Image.MODE_IMAGE; // Both are tracked If image data is not tracked, and the Image is drawn to an image rather than a PDF canvas, the output will be junk. Enabling mime data tracking has no benefits (only a slow down) unless you are generating a PDF. -### Canvas#pngStream() +### Canvas#pngStream(options) To create a `PNGStream` simply call `canvas.pngStream()`, and the stream will start to emit _data_ events, finally emitting _end_ when finished. If an exception occurs the _error_ event is emitted. @@ -137,6 +137,22 @@ stream.on('end', function(){ Currently _only_ sync streaming is supported, however we plan on supporting async streaming as well (of course :) ). Until then the `Canvas#toBuffer(callback)` alternative is async utilizing `eio_custom()`. +To encode indexed PNGs from canvases with `pixelFormat: 'A8'` or `'A1'`, provide an options object: + +```js +var palette = new Uint8ClampedArray([ + //r g b a + 0, 50, 50, 255, // index 1 + 10, 90, 90, 255, // index 2 + 127, 127, 255, 255 + // ... +]); +canvas.pngStream({ + palette: palette, + backgroundIndex: 0 // optional, defaults to 0 +}) +``` + ### Canvas#jpegStream() and Canvas#syncJPEGStream() You can likewise create a `JPEGStream` by calling `canvas.jpegStream()` with @@ -312,6 +328,76 @@ var canvas = new Canvas(200, 500, 'svg'); fs.writeFile('out.svg', canvas.toBuffer()); ``` +## Image pixel formats (experimental) + +node-canvas has experimental support for additional pixel formats, roughly +following the [Canvas color space proposal](https://github.com/WICG/canvas-color-space/blob/master/CanvasColorSpaceProposal.md). + +```js +var canvas = new Canvas(200, 200); +var ctx = canvas.getContext('2d', {pixelFormat: 'A8'}); +``` + +By default, canvases are created in the `RGBA32` format, which corresponds to +the native HTML Canvas behavior. Each pixel is 32 bits. The JavaScript APIs +that involve pixel data (`getImageData`, `putImageData`) store the colors in +the order {red, green, blue, alpha} without alpha pre-multiplication. (The C++ +API stores the colors in the order {alpha, red, green, blue} in native-[endian](https://en.wikipedia.org/wiki/Endianness) +ordering, with alpha pre-multiplication.) + +These additional pixel formats have experimental support: + +* `RGB24` Like `RGBA32`, but the 8 alpha bits are always opaque. This format is + always used if the `alpha` context attribute is set to false (i.e. + `canvas.getContext('2d', {alpha: false})`). This format can be faster than + `RGBA32` because transparency does not need to be calculated. +* `A8` Each pixel is 8 bits. This format can either be used for creating + grayscale images (treating each byte as an alpha value), or for creating + indexed PNGs (treating each byte as a palette index) (see [the example using + alpha values with `fillStyle`](examples/indexed-png-alpha.js) and [the + example using `imageData`](examples/indexed-png-image-data.js)). +* `RGB16_565` Each pixel is 16 bits, with red in the upper 5 bits, green in the + middle 6 bits, and blue in the lower 5 bits, in native platform endianness. + Some hardware devices and frame buffers use this format. Note that PNG does + not support this format; when creating a PNG, the image will be converted to + 24-bit RGB. This format is thus suboptimal for generating PNGs. + `ImageData` instances for this mode use a `Uint16Array` instead of a `Uint8ClampedArray`. +* `A1` Each pixel is 1 bit, and pixels are packed together into 32-bit + quantities. The ordering of the bits matches the endianness of the + platform: on a little-endian machine, the first pixel is the least- + significant bit. This format can be used for creating single-color images. + *Support for this format is incomplete, see note below.* +* `RGB30` Each pixel is 30 bits, with red in the upper 10, green + in the middle 10, and blue in the lower 10. (Requires Cairo 1.12 or later.) + *Support for this format is incomplete, see note below.* + +Notes and caveats: + +* Using a non-default format can affect the behavior of APIs that involve pixel + data: + + * `context2d.createImageData` The size of the array returned depends on the + number of bit per pixel for the underlying image data format, per the above + descriptions. + * `context2d.getImageData` The format of the array returned depends on the + underlying image mode, per the above descriptions. Be aware of platform + endianness, which can be determined using node.js's [`os.endianness()`](https://nodejs.org/api/os.html#os_os_endianness) + function. + * `context2d.putImageData` As above. + +* `A1` and `RGB30` do not yet support `getImageData` or `putImageData`. Have a + use case and/or opinion on working with these formats? Open an issue and let + us know! (See #935.) + +* `A1`, `A8`, `RGB30` and `RGB16_565` with shadow blurs may crash or not render + properly. + +* The `ImageData(width, height)` and `ImageData(Uint8ClampedArray, width)` + constructors assume 4 bytes per pixel. To create an `ImageData` instance with + a different number of bytes per pixel, use + `new ImageData(new Uint8ClampedArray(size), width, height)` or + `new ImageData(new Uint16ClampedArray(size), width, height)`. + ## Benchmarks Although node-canvas is extremely new, and we have not even begun optimization yet it is already quite fast. For benchmarks vs other node canvas implementations view this [gist](https://gist.github.com/664922), or update the submodules and run `$ make benchmark` yourself. diff --git a/examples/indexed-png-alpha.js b/examples/indexed-png-alpha.js new file mode 100644 index 000000000..86ff31010 --- /dev/null +++ b/examples/indexed-png-alpha.js @@ -0,0 +1,34 @@ +var Canvas = require('..') +var fs = require('fs') +var path = require('path') +var canvas = new Canvas(200, 200) +var ctx = canvas.getContext('2d', {pixelFormat: 'A8'}) + +// Matches the "fillStyle" browser test, made by using alpha fillStyle value +var palette = new Uint8ClampedArray(37 * 4) +var i, j +var k = 0 +// First value is opaque white: +palette[k++] = 255 +palette[k++] = 255 +palette[k++] = 255 +palette[k++] = 255 +for (i = 0; i < 6; i++) { + for (j = 0; j < 6; j++) { + palette[k++] = Math.floor(255 - 42.5 * i) + palette[k++] = Math.floor(255 - 42.5 * j) + palette[k++] = 0 + palette[k++] = 255 + } +} +for (i = 0; i < 6; i++) { + for (j = 0; j < 6; j++) { + var index = i * 6 + j + 1.5 // 0.5 to bias rounding + var fraction = index / 255 + ctx.fillStyle = 'rgba(0,0,0,' + fraction + ')' + ctx.fillRect(j * 25, i * 25, 25, 25) + } +} + +canvas.createPNGStream({palette: palette}) + .pipe(fs.createWriteStream(path.join(__dirname, 'indexed2.png'))) diff --git a/examples/indexed-png-image-data.js b/examples/indexed-png-image-data.js new file mode 100644 index 000000000..d675f1b1b --- /dev/null +++ b/examples/indexed-png-image-data.js @@ -0,0 +1,39 @@ +var Canvas = require('..') +var fs = require('fs') +var path = require('path') +var canvas = new Canvas(200, 200) +var ctx = canvas.getContext('2d', {pixelFormat: 'A8'}) + +// Matches the "fillStyle" browser test, made by manipulating imageData +var palette = new Uint8ClampedArray(37 * 4) +var k = 0 +var i, j +// First value is opaque white: +palette[k++] = 255 +palette[k++] = 255 +palette[k++] = 255 +palette[k++] = 255 +for (i = 0; i < 6; i++) { + for (j = 0; j < 6; j++) { + palette[k++] = Math.floor(255 - 42.5 * i) + palette[k++] = Math.floor(255 - 42.5 * j) + palette[k++] = 0 + palette[k++] = 255 + } +} +var idata = ctx.getImageData(0, 0, 200, 200) +for (i = 0; i < 6; i++) { + for (j = 0; j < 6; j++) { + var index = j * 6 + i + // fill rect: + for (var xr = j * 25; xr < j * 25 + 25; xr++) { + for (var yr = i * 25; yr < i * 25 + 25; yr++) { + idata.data[xr * 200 + yr] = index + 1 + } + } + } +} +ctx.putImageData(idata, 0, 0) + +canvas.createPNGStream({palette: palette}) + .pipe(fs.createWriteStream(path.join(__dirname, 'indexed.png'))) diff --git a/lib/canvas.js b/lib/canvas.js index fc77ff52e..30a0c59e6 100644 --- a/lib/canvas.js +++ b/lib/canvas.js @@ -112,14 +112,15 @@ Canvas.prototype.inspect = function(){ /** * Get a context object. * - * @param {String} contextId + * @param {String} contextType must be "2d" + * @param {Object {alpha: boolean, pixelFormat: PIXEL_FORMAT} } contextAttributes Optional * @return {Context2d} * @api public */ -Canvas.prototype.getContext = function(contextId){ - if ('2d' == contextId) { - var ctx = this._context2d || (this._context2d = new Context2d(this)); +Canvas.prototype.getContext = function (contextType, contextAttributes) { + if ('2d' == contextType) { + var ctx = this._context2d || (this._context2d = new Context2d(this, contextAttributes)); this.context = ctx; ctx.canvas = this; return ctx; @@ -129,25 +130,35 @@ Canvas.prototype.getContext = function(contextId){ /** * Create a `PNGStream` for `this` canvas. * + * @param {Object} options + * @param {Uint8ClampedArray} options.palette Provide for indexed PNG encoding. + * entries should be R-G-B-A values. + * @param {Number} options.backgroundIndex Optional index of background color + * for indexed PNGs. Defaults to 0. * @return {PNGStream} * @api public */ Canvas.prototype.pngStream = -Canvas.prototype.createPNGStream = function(){ - return new PNGStream(this); +Canvas.prototype.createPNGStream = function(options){ + return new PNGStream(this, false, options); }; /** * Create a synchronous `PNGStream` for `this` canvas. * + * @param {Object} options + * @param {Uint8ClampedArray} options.palette Provide for indexed PNG encoding. + * entries should be R-G-B-A values. + * @param {Number} options.backgroundIndex Optional index of background color + * for indexed PNGs. Defaults to 0. * @return {PNGStream} * @api public */ Canvas.prototype.syncPNGStream = -Canvas.prototype.createSyncPNGStream = function(){ - return new PNGStream(this, true); +Canvas.prototype.createSyncPNGStream = function(options){ + return new PNGStream(this, true, options); }; /** diff --git a/lib/context2d.js b/lib/context2d.js index 8ca28e762..8c9ac74e7 100644 --- a/lib/context2d.js +++ b/lib/context2d.js @@ -274,6 +274,13 @@ Context2d.prototype.createImageData = function (width, height) { height = width.height width = width.width } - - return new ImageData(width, height) + var Bpp = this.canvas.stride / this.canvas.width; + var nBytes = Bpp * width * height + var arr; + if (this.pixelFormat === "RGB16_565") { + arr = new Uint16Array(nBytes / 2); + } else { + arr = new Uint8ClampedArray(nBytes); + } + return new ImageData(arr, width, height); } diff --git a/lib/pngstream.js b/lib/pngstream.js index 3a50dbaf0..fb1469185 100644 --- a/lib/pngstream.js +++ b/lib/pngstream.js @@ -27,10 +27,15 @@ var util = require('util'); * * @param {Canvas} canvas * @param {Boolean} sync + * @param {Object} options + * @param {Uint8ClampedArray} options.palette Provide for indexed PNG encoding. + * entries should be R-G-B-A values. + * @param {Number} options.backgroundIndex Optional index of background color + * for indexed PNGs. Defaults to 0. * @api public */ -var PNGStream = module.exports = function PNGStream(canvas, sync) { +var PNGStream = module.exports = function PNGStream(canvas, sync, options) { if (!(this instanceof PNGStream)) { throw new TypeError("Class constructors cannot be invoked without 'new'"); } @@ -43,6 +48,7 @@ var PNGStream = module.exports = function PNGStream(canvas, sync) { : 'streamPNG'; this.sync = sync; this.canvas = canvas; + this.options = options || {}; // TODO: implement async if ('streamPNG' === method) method = 'streamPNGSync'; @@ -66,5 +72,5 @@ PNGStream.prototype._read = function _read() { } else { self.push(null); } - }); + }, self.options); }; diff --git a/src/Canvas.cc b/src/Canvas.cc index 0a5752f41..61cf2134f 100644 --- a/src/Canvas.cc +++ b/src/Canvas.cc @@ -103,6 +103,7 @@ NAN_METHOD(Canvas::New) { backend = new ImageBackend(width, height); } else if (info[0]->IsObject()) { + // TODO need to check if this is actually an instance of a Backend to avoid a fault backend = Nan::ObjectWrap::Unwrap(info[0]->ToObject()); } else { @@ -304,6 +305,8 @@ NAN_METHOD(Canvas::ToBuffer) { uv_work_t* req = new uv_work_t; req->data = closure; + // Make sure the surface exists since we won't have an isolate context in the async block: + canvas->surface(); uv_queue_work(uv_default_loop(), req, ToBufferAsync, (uv_after_work_cb)ToBufferAsyncAfter); return; @@ -356,6 +359,10 @@ streamPNG(void *c, const uint8_t *data, unsigned len) { /* * Stream PNG data synchronously. + * TODO the compression level and filter args don't seem to be documented. + * Maybe move them to named properties in the options object? + * StreamPngSync(this, options: {palette?: Uint8ClampedArray}) + * StreamPngSync(this, compression_level?: uint32, filter?: uint32) */ NAN_METHOD(Canvas::StreamPNGSync) { @@ -365,6 +372,11 @@ NAN_METHOD(Canvas::StreamPNGSync) { if (!info[0]->IsFunction()) return Nan::ThrowTypeError("callback function required"); + Canvas *canvas = Nan::ObjectWrap::Unwrap(info.This()); + uint8_t* paletteColors = NULL; + size_t nPaletteColors = 0; + uint8_t backgroundIndex = 0; + if (info.Length() > 1 && !(info[1]->IsUndefined() && info[2]->IsUndefined())) { if (!info[1]->IsUndefined()) { bool good = true; @@ -381,9 +393,32 @@ NAN_METHOD(Canvas::StreamPNGSync) { compression_level = tmp; } } - } else { - good = false; - } + } else if (info[1]->IsObject()) { + // If canvas is A8 or A1 and options obj has Uint8ClampedArray palette, + // encode as indexed PNG. + cairo_format_t format = canvas->backend()->getFormat(); + if (format == CAIRO_FORMAT_A8 || format == CAIRO_FORMAT_A1) { + Local attrs = info[1]->ToObject(); + Local palette = attrs->Get(Nan::New("palette").ToLocalChecked()); + if (palette->IsUint8ClampedArray()) { + Local palette_ta = palette.As(); + nPaletteColors = palette_ta->Length(); + if (nPaletteColors % 4 != 0) { + Nan::ThrowError("Palette length must be a multiple of 4."); + } + nPaletteColors /= 4; + Nan::TypedArrayContents _paletteColors(palette_ta); + paletteColors = *_paletteColors; + // Optional background color index: + Local backgroundIndexVal = attrs->Get(Nan::New("backgroundIndex").ToLocalChecked()); + if (backgroundIndexVal->IsUint32()) { + backgroundIndex = static_cast(backgroundIndexVal->Uint32Value()); + } + } + } + } else { + good = false; + } if (good) { if (compression_level > 9) { @@ -404,11 +439,13 @@ NAN_METHOD(Canvas::StreamPNGSync) { } - Canvas *canvas = Nan::ObjectWrap::Unwrap(info.This()); closure_t closure; closure.fn = Local::Cast(info[0]); closure.compression_level = compression_level; closure.filter = filter; + closure.palette = paletteColors; + closure.nPaletteColors = nPaletteColors; + closure.backgroundIndex = backgroundIndex; Nan::TryCatch try_catch; diff --git a/src/CanvasRenderingContext2d.cc b/src/CanvasRenderingContext2d.cc index f13b6d81e..e00a11206 100644 --- a/src/CanvasRenderingContext2d.cc +++ b/src/CanvasRenderingContext2d.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include "Canvas.h" #include "Point.h" @@ -18,6 +19,7 @@ #include "CanvasRenderingContext2d.h" #include "CanvasGradient.h" #include "CanvasPattern.h" +#include "backend/ImageBackend.h" // Windows doesn't support the C99 names for these #ifdef _MSC_VER @@ -123,6 +125,7 @@ Context2d::Initialize(Nan::ADDON_REGISTER_FUNCTION_ARGS_TYPE target) { Nan::SetPrototypeMethod(ctor, "_setStrokePattern", SetStrokePattern); Nan::SetPrototypeMethod(ctor, "_setTextBaseline", SetTextBaseline); Nan::SetPrototypeMethod(ctor, "_setTextAlignment", SetTextAlignment); + Nan::SetAccessor(proto, Nan::New("pixelFormat").ToLocalChecked(), GetFormat); Nan::SetAccessor(proto, Nan::New("patternQuality").ToLocalChecked(), GetPatternQuality, SetPatternQuality); Nan::SetAccessor(proto, Nan::New("globalCompositeOperation").ToLocalChecked(), GetGlobalCompositeOperation, SetGlobalCompositeOperation); Nan::SetAccessor(proto, Nan::New("globalAlpha").ToLocalChecked(), GetGlobalAlpha, SetGlobalAlpha); @@ -501,11 +504,61 @@ NAN_METHOD(Context2d::New) { if (!Nan::New(Canvas::constructor)->HasInstance(obj)) return Nan::ThrowTypeError("Canvas expected"); Canvas *canvas = Nan::ObjectWrap::Unwrap(obj); + + bool isImageBackend = canvas->backend()->getName() == "image"; + if (isImageBackend) { + cairo_format_t format = ImageBackend::DEFAULT_FORMAT; + if (info[1]->IsObject()) { + Local ctxAttributes = info[1]->ToObject(); + + Local pixelFormat = ctxAttributes->Get(Nan::New("pixelFormat").ToLocalChecked()); + if (pixelFormat->IsString()) { + String::Utf8Value utf8PixelFormat(pixelFormat); + if (!strcmp(*utf8PixelFormat, "RGBA32")) format = CAIRO_FORMAT_ARGB32; + else if (!strcmp(*utf8PixelFormat, "RGB24")) format = CAIRO_FORMAT_RGB24; + else if (!strcmp(*utf8PixelFormat, "A8")) format = CAIRO_FORMAT_A8; + else if (!strcmp(*utf8PixelFormat, "RGB16_565")) format = CAIRO_FORMAT_RGB16_565; + else if (!strcmp(*utf8PixelFormat, "A1")) format = CAIRO_FORMAT_A1; +#ifdef CAIRO_FORMAT_RGB30 + else if (!strcmp(utf8PixelFormat, "RGB30")) format = CAIRO_FORMAT_RGB30; +#endif + } + + // alpha: false forces use of RGB24 + Local alpha = ctxAttributes->Get(Nan::New("alpha").ToLocalChecked()); + if (alpha->IsBoolean() && !alpha->BooleanValue()) { + format = CAIRO_FORMAT_RGB24; + } + } + static_cast(canvas->backend())->setFormat(format); + } + Context2d *context = new Context2d(canvas); context->Wrap(info.This()); info.GetReturnValue().Set(info.This()); } +/* +* Get format (string). +*/ + +NAN_GETTER(Context2d::GetFormat) { + Context2d *context = Nan::ObjectWrap::Unwrap(info.This()); + std::string pixelFormatString; + switch (context->canvas()->backend()->getFormat()) { + case CAIRO_FORMAT_ARGB32: pixelFormatString = "RGBA32"; break; + case CAIRO_FORMAT_RGB24: pixelFormatString = "RGB24"; break; + case CAIRO_FORMAT_A8: pixelFormatString = "A8"; break; + case CAIRO_FORMAT_A1: pixelFormatString = "A1"; break; + case CAIRO_FORMAT_RGB16_565: pixelFormatString = "RGB16_565"; break; +#ifdef CAIRO_FORMAT_RGB30 + case CAIRO_FORMAT_RGB30: pixelFormatString = "RGB30"; break; +#endif + default: return info.GetReturnValue().SetNull(); + } + info.GetReturnValue().Set(Nan::New(pixelFormatString).ToLocalChecked()); +} + /* * Create a new page. */ @@ -540,8 +593,9 @@ NAN_METHOD(Context2d::PutImageData) { uint8_t *src = imageData->data(); uint8_t *dst = context->canvas()->data(); - int srcStride = imageData->stride() - , dstStride = context->canvas()->stride(); + int dstStride = context->canvas()->stride(); + int Bpp = dstStride / context->canvas()->getWidth(); + int srcStride = Bpp * imageData->width(); int sx = 0 , sy = 0 @@ -593,41 +647,111 @@ NAN_METHOD(Context2d::PutImageData) { if (cols <= 0 || rows <= 0) return; - src += sy * srcStride + sx * 4; - dst += dstStride * dy + 4 * dx; - for (int y = 0; y < rows; ++y) { - uint8_t *dstRow = dst; - uint8_t *srcRow = src; - for (int x = 0; x < cols; ++x) { - // rgba - uint8_t r = *srcRow++; - uint8_t g = *srcRow++; - uint8_t b = *srcRow++; - uint8_t a = *srcRow++; - - // argb - // performance optimization: fully transparent/opaque pixels can be - // processed more efficiently. - if (a == 0) { - *dstRow++ = 0; - *dstRow++ = 0; - *dstRow++ = 0; - *dstRow++ = 0; - } else if (a == 255) { + switch (context->canvas()->backend()->getFormat()) { + case CAIRO_FORMAT_ARGB32: { + src += sy * srcStride + sx * 4; + dst += dstStride * dy + 4 * dx; + for (int y = 0; y < rows; ++y) { + uint8_t *dstRow = dst; + uint8_t *srcRow = src; + for (int x = 0; x < cols; ++x) { + // rgba + uint8_t r = *srcRow++; + uint8_t g = *srcRow++; + uint8_t b = *srcRow++; + uint8_t a = *srcRow++; + + // argb + // performance optimization: fully transparent/opaque pixels can be + // processed more efficiently. + if (a == 0) { + *dstRow++ = 0; + *dstRow++ = 0; + *dstRow++ = 0; + *dstRow++ = 0; + } else if (a == 255) { + *dstRow++ = b; + *dstRow++ = g; + *dstRow++ = r; + *dstRow++ = a; + } else { + float alpha = (float)a / 255; + *dstRow++ = b * alpha; + *dstRow++ = g * alpha; + *dstRow++ = r * alpha; + *dstRow++ = a; + } + } + dst += dstStride; + src += srcStride; + } + break; + } + case CAIRO_FORMAT_RGB24: { + src += sy * srcStride + sx * 4; + dst += dstStride * dy + 4 * dx; + for (int y = 0; y < rows; ++y) { + uint8_t *dstRow = dst; + uint8_t *srcRow = src; + for (int x = 0; x < cols; ++x) { + // rgba + uint8_t r = *srcRow++; + uint8_t g = *srcRow++; + uint8_t b = *srcRow++; + srcRow++; + + // argb *dstRow++ = b; *dstRow++ = g; *dstRow++ = r; - *dstRow++ = a; - } else { - float alpha = (float)a / 255; - *dstRow++ = b * alpha; - *dstRow++ = g * alpha; - *dstRow++ = r * alpha; - *dstRow++ = a; + *dstRow++ = 255; } + dst += dstStride; + src += srcStride; } - dst += dstStride; - src += srcStride; + break; + } + case CAIRO_FORMAT_A8: { + src += sy * srcStride + sx; + dst += dstStride * dy + dx; + if (srcStride == dstStride && cols == dstStride) { + // fast path: strides are the same and doing a full-width put + memcpy(dst, src, cols * rows); + } else { + for (int y = 0; y < rows; ++y) { + memcpy(dst, src, cols); + dst += dstStride; + src += srcStride; + } + } + break; + } + case CAIRO_FORMAT_A1: { + // TODO Should this be totally packed, or maintain a stride divisible by 4? + Nan::ThrowError("putImageData for CANVAS_FORMAT_A1 is not yet implemented"); + break; + } + case CAIRO_FORMAT_RGB16_565: { + src += sy * srcStride + sx * 2; + dst += dstStride * dy + 2 * dx; + for (int y = 0; y < rows; ++y) { + memcpy(dst, src, cols * 2); + dst += dstStride; + src += srcStride; + } + break; + } +#ifdef CAIRO_FORMAT_RGB30 + case CAIRO_FORMAT_RGB30: { + // TODO + Nan::ThrowError("putImageData for CANVAS_FORMAT_RGB30 is not yet implemented"); + break; + } +#endif + default: { + Nan::ThrowError("Invalid pixel format"); + break; + } } cairo_surface_mark_dirty_rectangle( @@ -690,52 +814,118 @@ NAN_METHOD(Context2d::GetImageData) { sy = 0; } - int size = sw * sh * 4; - int srcStride = canvas->stride(); - int dstStride = sw * 4; + int bpp = srcStride / canvas->getWidth(); + int size = sw * sh * bpp; + int dstStride = sw * bpp; uint8_t *src = canvas->data(); Local buffer = ArrayBuffer::New(Isolate::GetCurrent(), size); - Local clampedArray = Uint8ClampedArray::New(buffer, 0, size); + Local dataArray; - Nan::TypedArrayContents typedArrayContents(clampedArray); + if (canvas->backend()->getFormat() == CAIRO_FORMAT_RGB16_565) { + dataArray = Uint16Array::New(buffer, 0, size); + } else { + dataArray = Uint8ClampedArray::New(buffer, 0, size); + } + + Nan::TypedArrayContents typedArrayContents(dataArray); uint8_t* dst = *typedArrayContents; - // Normalize data (argb -> rgba) - for (int y = 0; y < sh; ++y) { + switch (canvas->backend()->getFormat()) { + case CAIRO_FORMAT_ARGB32: { + // Rearrange alpha (argb -> rgba), undo alpha pre-multiplication, + // and store in big-endian format + for (int y = 0; y < sh; ++y) { + uint32_t *row = (uint32_t *)(src + srcStride * (y + sy)); + for (int x = 0; x < sw; ++x) { + int bx = x * 4; + uint32_t *pixel = row + x + sx; + uint8_t a = *pixel >> 24; + uint8_t r = *pixel >> 16; + uint8_t g = *pixel >> 8; + uint8_t b = *pixel; + dst[bx + 3] = a; + + // Performance optimization: fully transparent/opaque pixels can be + // processed more efficiently. + if (a == 0 || a == 255) { + dst[bx + 0] = r; + dst[bx + 1] = g; + dst[bx + 2] = b; + } else { + // Undo alpha pre-multiplication + float alphaR = (float)255 / a; + dst[bx + 0] = (int)((float)r * alphaR); + dst[bx + 1] = (int)((float)g * alphaR); + dst[bx + 2] = (int)((float)b * alphaR); + } + + } + dst += dstStride; + } + break; + } + case CAIRO_FORMAT_RGB24: { + // Rearrange alpha (argb -> rgba) and store in big-endian format + for (int y = 0; y < sh; ++y) { uint32_t *row = (uint32_t *)(src + srcStride * (y + sy)); for (int x = 0; x < sw; ++x) { int bx = x * 4; uint32_t *pixel = row + x + sx; - uint8_t a = *pixel >> 24; uint8_t r = *pixel >> 16; uint8_t g = *pixel >> 8; uint8_t b = *pixel; - dst[bx + 3] = a; - - // Performance optimization: fully transparent/opaque pixels can be - // processed more efficiently. - if (a == 0 || a == 255) { - dst[bx + 0] = r; - dst[bx + 1] = g; - dst[bx + 2] = b; - } else { - float alpha = (float)a / 255; - dst[bx + 0] = (int)((float)r / alpha); - dst[bx + 1] = (int)((float)g / alpha); - dst[bx + 2] = (int)((float)b / alpha); - } + dst[bx + 0] = r; + dst[bx + 1] = g; + dst[bx + 2] = b; + dst[bx + 3] = 255; } dst += dstStride; + } + break; + } + case CAIRO_FORMAT_A8: { + for (int y = 0; y < sh; ++y) { + uint8_t *row = (uint8_t *)(src + srcStride * (y + sy)); + memcpy(dst, row + sx, dstStride); + dst += dstStride; + } + break; + } + case CAIRO_FORMAT_A1: { + // TODO Should this be totally packed, or maintain a stride divisible by 4? + Nan::ThrowError("getImageData for CANVAS_FORMAT_A1 is not yet implemented"); + break; + } + case CAIRO_FORMAT_RGB16_565: { + for (int y = 0; y < sh; ++y) { + uint16_t *row = (uint16_t *)(src + srcStride * (y + sy)); + memcpy(dst, row + sx, dstStride); + dst += dstStride; + } + break; + } +#ifdef CAIRO_FORMAT_RGB30 + case CAIRO_FORMAT_RGB30: { + // TODO + Nan::ThrowError("getImageData for CANVAS_FORMAT_RGB30 is not yet implemented"); + break; + } +#endif + default: { + // Unlikely + Nan::ThrowError("Invalid pixel format"); + break; + } } const int argc = 3; Local swHandle = Nan::New(sw); Local shHandle = Nan::New(sh); - Local argv[argc] = { clampedArray, swHandle, shHandle }; + Local argv[argc] = { dataArray, swHandle, shHandle }; Local ctor = Nan::GetFunction(Nan::New(ImageData::constructor)).ToLocalChecked(); Local instance = Nan::NewInstance(ctor, argc, argv).ToLocalChecked(); diff --git a/src/CanvasRenderingContext2d.h b/src/CanvasRenderingContext2d.h index fccb5d184..a43881441 100644 --- a/src/CanvasRenderingContext2d.h +++ b/src/CanvasRenderingContext2d.h @@ -98,6 +98,7 @@ class Context2d: public Nan::ObjectWrap { static NAN_METHOD(Arc); static NAN_METHOD(ArcTo); static NAN_METHOD(GetImageData); + static NAN_GETTER(GetFormat); static NAN_GETTER(GetPatternQuality); static NAN_GETTER(GetGlobalCompositeOperation); static NAN_GETTER(GetGlobalAlpha); diff --git a/src/ImageData.cc b/src/ImageData.cc index 709dae182..1d47a01ed 100644 --- a/src/ImageData.cc +++ b/src/ImageData.cc @@ -39,7 +39,7 @@ NAN_METHOD(ImageData::New) { return Nan::ThrowTypeError("Class constructors cannot be invoked without 'new'"); } - Local clampedArray; + Local dataArray; uint32_t width; uint32_t height; int length; @@ -55,46 +55,69 @@ NAN_METHOD(ImageData::New) { Nan::ThrowRangeError("The source height is zero."); return; } - length = width * height * 4; + length = width * height * 4; // ImageData(w, h) constructor assumes 4 BPP; documented. - clampedArray = Uint8ClampedArray::New(ArrayBuffer::New(Isolate::GetCurrent(), length), 0, length); + dataArray = Uint8ClampedArray::New(ArrayBuffer::New(Isolate::GetCurrent(), length), 0, length); } else if (info[0]->IsUint8ClampedArray() && info[1]->IsUint32()) { - clampedArray = info[0].As(); - length = clampedArray->Length(); + dataArray = info[0].As(); + + length = dataArray->Length(); if (length == 0) { Nan::ThrowRangeError("The input data has a zero byte length."); return; } - if (length % 4 != 0) { - Nan::ThrowRangeError("The input data byte length is not a multiple of 4."); - return; - } + + // Don't assert that the ImageData length is a multiple of four because some + // data formats are not 4 BPP. + width = info[1]->Uint32Value(); - int size = length / 4; if (width == 0) { Nan::ThrowRangeError("The source width is zero."); return; } - if (size % width != 0) { - Nan::ThrowRangeError("The input data byte length is not a multiple of (4 * width)."); - return; + + // Don't assert that the byte length is a multiple of 4 * width, ditto. + + if (info[2]->IsUint32()) { // Explicit height given + height = info[2]->Uint32Value(); + } else { // Calculate height assuming 4 BPP + int size = length / 4; + height = size / width; } + + } else if (info[0]->IsUint16Array() && info[1]->IsUint32()) { // Intended for RGB16_565 format + dataArray = info[0].As(); + + length = dataArray->Length(); + if (length == 0) { + Nan::ThrowRangeError("The input data has a zero byte length."); + return; + } + + width = info[1]->Uint32Value(); + if (width == 0) { + Nan::ThrowRangeError("The source width is zero."); + return; + } + + if (info[2]->IsUint32()) { // Explicit height given + height = info[2]->Uint32Value(); + } else { // Calculate height assuming 2 BPP + int size = length / 2; height = size / width; - if (info[2]->IsUint32() && info[2]->Uint32Value() != height) { - Nan::ThrowRangeError("The input data byte length is not equal to (4 * width * height)."); - return; - } + } + } else { - Nan::ThrowTypeError("Expected (Uint8ClampedArray, width[, height]) or (width, height)"); + Nan::ThrowTypeError("Expected (Uint8ClampedArray, width[, height]), (Uint16Array, width[, height]) or (width, height)"); return; } - Nan::TypedArrayContents dataPtr(clampedArray); + Nan::TypedArrayContents dataPtr(dataArray); ImageData *imageData = new ImageData(reinterpret_cast(*dataPtr), width, height); imageData->Wrap(info.This()); - info.This()->Set(Nan::New("data").ToLocalChecked(), clampedArray); + info.This()->Set(Nan::New("data").ToLocalChecked(), dataArray); info.GetReturnValue().Set(info.This()); } diff --git a/src/ImageData.h b/src/ImageData.h index 074d1ff8a..8007f4d31 100644 --- a/src/ImageData.h +++ b/src/ImageData.h @@ -23,7 +23,6 @@ class ImageData: public Nan::ObjectWrap { inline int width() { return _width; } inline int height() { return _height; } inline uint8_t *data() { return _data; } - inline int stride() { return _width * 4; } ImageData(uint8_t *data, int width, int height) : _width(width), _height(height), _data(data) {} private: diff --git a/src/PNG.h b/src/PNG.h index 4cdb78919..944795f24 100644 --- a/src/PNG.h +++ b/src/PNG.h @@ -63,12 +63,34 @@ static void canvas_unpremultiply_data(png_structp png, png_row_infop row_info, p } } +/* Converts RGB16_565 format data to RGBA32 */ +static void canvas_convert_565_to_888(png_structp png, png_row_infop row_info, png_bytep data) { + // Loop in reverse to unpack in-place. + for (ptrdiff_t col = row_info->width - 1; col >= 0; col--) { + uint8_t* src = &data[col * sizeof(uint16_t)]; + uint8_t* dst = &data[col * 3]; + uint16_t pixel; + + memcpy(&pixel, src, sizeof(uint16_t)); + + // Convert and rescale to the full 0-255 range + // See http://stackoverflow.com/a/29326693 + const uint8_t red5 = (pixel & 0xF800) >> 11; + const uint8_t green6 = (pixel & 0x7E0) >> 5; + const uint8_t blue5 = (pixel & 0x001F); + + dst[0] = ((red5 * 255 + 15) / 31); + dst[1] = ((green6 * 255 + 31) / 63); + dst[2] = ((blue5 * 255 + 15) / 31); + } +} + struct canvas_png_write_closure_t { cairo_write_func_t write_func; - void *closure; + closure_t *closure; }; -static cairo_status_t canvas_write_png(cairo_surface_t *surface, png_rw_ptr write_func, void *closure) { +static cairo_status_t canvas_write_png(cairo_surface_t *surface, png_rw_ptr write_func, canvas_png_write_closure_t *closure) { unsigned int i; cairo_status_t status = CAIRO_STATUS_SUCCESS; uint8_t *data; @@ -99,8 +121,9 @@ static cairo_status_t canvas_write_png(cairo_surface_t *surface, png_rw_ptr writ return status; } + int stride = cairo_image_surface_get_stride(surface); for (i = 0; i < height; i++) { - rows[i] = (png_byte *) data + i * cairo_image_surface_get_stride(surface); + rows[i] = (png_byte *) data + i * stride; } #ifdef PNG_USER_MEM_SUPPORTED @@ -133,10 +156,12 @@ static cairo_status_t canvas_write_png(cairo_surface_t *surface, png_rw_ptr writ #endif png_set_write_fn(png, closure, write_func, canvas_png_flush); - png_set_compression_level(png, ((closure_t *) ((canvas_png_write_closure_t *) closure)->closure)->compression_level); - png_set_filter(png, 0, ((closure_t *) ((canvas_png_write_closure_t *) closure)->closure)->filter); + png_set_compression_level(png, closure->closure->compression_level); + png_set_filter(png, 0, closure->closure->filter); + + cairo_format_t format = cairo_image_surface_get_format(surface); - switch (cairo_image_surface_get_format(surface)) { + switch (format) { case CAIRO_FORMAT_ARGB32: bpc = 8; png_color_type = PNG_COLOR_TYPE_RGB_ALPHA; @@ -162,8 +187,11 @@ static cairo_status_t canvas_write_png(cairo_surface_t *surface, png_rw_ptr writ png_set_packswap(png); #endif break; - case CAIRO_FORMAT_INVALID: case CAIRO_FORMAT_RGB16_565: + bpc = 8; // 565 gets upconverted to 888 + png_color_type = PNG_COLOR_TYPE_RGB; + break; + case CAIRO_FORMAT_INVALID: default: status = CAIRO_STATUS_INVALID_FORMAT; png_destroy_write_struct(&png, &info); @@ -171,11 +199,40 @@ static cairo_status_t canvas_write_png(cairo_surface_t *surface, png_rw_ptr writ return status; } + if ((format == CAIRO_FORMAT_A8 || format == CAIRO_FORMAT_A1) && + closure->closure->palette != NULL) { + png_color_type = PNG_COLOR_TYPE_PALETTE; + } + png_set_IHDR(png, info, width, height, bpc, png_color_type, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); - white.gray = (1 << bpc) - 1; - white.red = white.blue = white.green = white.gray; - png_set_bKGD(png, info, &white); + if (png_color_type == PNG_COLOR_TYPE_PALETTE) { + size_t nColors = closure->closure->nPaletteColors; + uint8_t* colors = closure->closure->palette; + uint8_t backgroundIndex = closure->closure->backgroundIndex; + png_colorp pngPalette = (png_colorp)png_malloc(png, nColors * sizeof(png_colorp)); + png_bytep transparency = (png_bytep)png_malloc(png, nColors * sizeof(png_bytep)); + for (i = 0; i < nColors; i++) { + pngPalette[i].red = colors[4 * i]; + pngPalette[i].green = colors[4 * i + 1]; + pngPalette[i].blue = colors[4 * i + 2]; + transparency[i] = colors[4 * i + 3]; + } + png_set_PLTE(png, info, pngPalette, nColors); + png_set_tRNS(png, info, transparency, nColors, NULL); + png_set_packing(png); // pack pixels + // have libpng free palette and trans: + png_data_freer(png, info, PNG_DESTROY_WILL_FREE_DATA, PNG_FREE_PLTE | PNG_FREE_TRNS); + png_color_16 bkg; + bkg.index = backgroundIndex; + png_set_bKGD(png, info, &bkg); + } + + if (png_color_type != PNG_COLOR_TYPE_PALETTE) { + white.gray = (1 << bpc) - 1; + white.red = white.blue = white.green = white.gray; + png_set_bKGD(png, info, &white); + } /* We have to call png_write_info() before setting up the write * transformation, since it stores data internally in 'png' @@ -184,6 +241,8 @@ static cairo_status_t canvas_write_png(cairo_surface_t *surface, png_rw_ptr writ png_write_info(png, info); if (png_color_type == PNG_COLOR_TYPE_RGB_ALPHA) { png_set_write_user_transform_fn(png, canvas_unpremultiply_data); + } else if (format == CAIRO_FORMAT_RGB16_565) { + png_set_write_user_transform_fn(png, canvas_convert_565_to_888); } else if (png_color_type == PNG_COLOR_TYPE_RGB) { png_set_write_user_transform_fn(png, canvas_convert_data_to_bytes); png_set_filler(png, 0, PNG_FILLER_AFTER); @@ -212,7 +271,7 @@ static void canvas_stream_write_func(png_structp png, png_bytep data, png_size_t } } -static cairo_status_t canvas_write_to_png_stream(cairo_surface_t *surface, cairo_write_func_t write_func, void *closure) { +static cairo_status_t canvas_write_to_png_stream(cairo_surface_t *surface, cairo_write_func_t write_func, closure_t *closure) { struct canvas_png_write_closure_t png_closure; if (cairo_surface_status(surface)) { diff --git a/src/backend/Backend.cc b/src/backend/Backend.cc index e2a2e9a5a..b3e3dac45 100644 --- a/src/backend/Backend.cc +++ b/src/backend/Backend.cc @@ -29,8 +29,8 @@ cairo_surface_t* Backend::recreateSurface() return this->createSurface(); } -cairo_surface_t* Backend::getSurface() -{ +cairo_surface_t* Backend::getSurface() { + if (!surface) createSurface(); return surface; } diff --git a/src/backend/Backend.h b/src/backend/Backend.h index 5877c34e0..a20215355 100644 --- a/src/backend/Backend.h +++ b/src/backend/Backend.h @@ -53,6 +53,9 @@ class Backend : public Nan::ObjectWrap int getHeight(); virtual void setHeight(int height); + + // Overridden by ImageBackend. SVG and PDF thus always return INVALID. + virtual cairo_format_t getFormat() { return CAIRO_FORMAT_INVALID; } }; diff --git a/src/backend/ImageBackend.cc b/src/backend/ImageBackend.cc index 5c64e5708..2aa91a108 100644 --- a/src/backend/ImageBackend.cc +++ b/src/backend/ImageBackend.cc @@ -4,23 +4,42 @@ using namespace v8; ImageBackend::ImageBackend(int width, int height) : Backend("image", width, height) -{ - createSurface(); -} + {} ImageBackend::~ImageBackend() { destroySurface(); - Nan::AdjustExternalMemory(-4 * width * height); + Nan::AdjustExternalMemory(-1 * approxBytesPerPixel() * width * height); +} + +// This returns an approximate value only, suitable for Nan::AdjustExternalMemory. +// The formats that don't map to intrinsic types (RGB30, A1) round up. +uint32_t ImageBackend::approxBytesPerPixel() { + switch (format) { + case CAIRO_FORMAT_ARGB32: + case CAIRO_FORMAT_RGB24: + return 4; +#ifdef CAIRO_FORMAT_RGB30 + case CAIRO_FORMAT_RGB30: + return 3; +#endif + case CAIRO_FORMAT_RGB16_565: + return 2; + case CAIRO_FORMAT_A8: + case CAIRO_FORMAT_A1: + return 1; + default: + return 0; + } } cairo_surface_t* ImageBackend::createSurface() { assert(!this->surface); - this->surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height); + this->surface = cairo_image_surface_create(this->format, width, height); assert(this->surface); - Nan::AdjustExternalMemory(4 * width * height); + Nan::AdjustExternalMemory(approxBytesPerPixel() * width * height); return this->surface; } @@ -28,14 +47,24 @@ cairo_surface_t* ImageBackend::createSurface() cairo_surface_t* ImageBackend::recreateSurface() { // Re-surface - int old_width = cairo_image_surface_get_width(this->surface); - int old_height = cairo_image_surface_get_height(this->surface); - this->destroySurface(); - Nan::AdjustExternalMemory(-4 * old_width * old_height); + if (this->surface) { + int old_width = cairo_image_surface_get_width(this->surface); + int old_height = cairo_image_surface_get_height(this->surface); + this->destroySurface(); + Nan::AdjustExternalMemory(-1 * approxBytesPerPixel() * old_width * old_height); + } return createSurface(); } +cairo_format_t ImageBackend::getFormat() { + return format; +} + +void ImageBackend::setFormat(cairo_format_t _format) { + this->format = _format; +} + Nan::Persistent ImageBackend::constructor; void ImageBackend::Initialize(Handle target) diff --git a/src/backend/ImageBackend.h b/src/backend/ImageBackend.h index fe5482aa2..2e5a82a7a 100644 --- a/src/backend/ImageBackend.h +++ b/src/backend/ImageBackend.h @@ -12,14 +12,21 @@ class ImageBackend : public Backend private: cairo_surface_t* createSurface(); cairo_surface_t* recreateSurface(); + cairo_format_t format = DEFAULT_FORMAT; public: ImageBackend(int width, int height); ~ImageBackend(); + cairo_format_t getFormat(); + void setFormat(cairo_format_t format); + + uint32_t approxBytesPerPixel(); + static Nan::Persistent constructor; static void Initialize(v8::Handle target); static NAN_METHOD(New); + const static cairo_format_t DEFAULT_FORMAT = CAIRO_FORMAT_ARGB32; }; #endif diff --git a/src/closure.h b/src/closure.h index e15a36730..76cd2fe15 100644 --- a/src/closure.h +++ b/src/closure.h @@ -34,6 +34,9 @@ typedef struct { cairo_status_t status; uint32_t compression_level; uint32_t filter; + uint8_t *palette; + size_t nPaletteColors; + uint8_t backgroundIndex; } closure_t; /* diff --git a/test/canvas.test.js b/test/canvas.test.js index 8678bf4d5..a307f51af 100644 --- a/test/canvas.test.js +++ b/test/canvas.test.js @@ -222,6 +222,54 @@ describe('Canvas', function () { assert.equal(ctx, canvas.context, 'canvas.context is not context'); }); + it('Canvas#getContext("2d", {pixelFormat: string})', function () { + var canvas, context; + + // default: + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {pixelFormat: "RGBA32"}); + assert.equal(context.pixelFormat, "RGBA32"); + + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {pixelFormat: "RGBA32"}); + assert.equal(context.pixelFormat, "RGBA32"); + + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {pixelFormat: "RGB24"}); + assert.equal(context.pixelFormat, "RGB24"); + + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {pixelFormat: "A8"}); + assert.equal(context.pixelFormat, "A8"); + + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {pixelFormat: "A1"}); + assert.equal(context.pixelFormat, "A1"); + + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {pixelFormat: "RGB16_565"}); + assert.equal(context.pixelFormat, "RGB16_565"); + + // Not tested: RGB30 + }); + + it('Canvas#getContext("2d", {alpha: boolean})', function () { + var canvas, context; + + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {alpha: true}); + assert.equal(context.pixelFormat, "RGBA32"); + + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {alpha: false}); + assert.equal(context.pixelFormat, "RGB24"); + + // alpha takes priority: + canvas = createCanvas(10, 10); + context = canvas.getContext("2d", {pixelFormat: "RGBA32", alpha: false}); + assert.equal(context.pixelFormat, "RGB24"); + }); + it('Canvas#{width,height}=', function () { var canvas = createCanvas(100, 200); assert.equal(100, canvas.width); @@ -241,6 +289,8 @@ describe('Canvas', function () { var canvas = createCanvas(24, 10); assert.ok(canvas.stride >= 24, 'canvas.stride is too short'); assert.ok(canvas.stride < 1024, 'canvas.stride seems too long'); + + // TODO test stride on other formats }); it('Canvas#getContext("invalid")', function () { @@ -605,19 +655,78 @@ describe('Canvas', function () { }); }); - it('Context2d#createImageData(width, height)', function () { - var canvas = createCanvas(20, 20) - , ctx = canvas.getContext('2d'); + describe('Context2d#createImageData(width, height)', function () { + it("works", function () { + var canvas = createCanvas(20, 20) + , ctx = canvas.getContext('2d'); - var imageData = ctx.createImageData(2,6); - assert.equal(2, imageData.width); - assert.equal(6, imageData.height); - assert.equal(2 * 6 * 4, imageData.data.length); + var imageData = ctx.createImageData(2,6); + assert.equal(2, imageData.width); + assert.equal(6, imageData.height); + assert.equal(2 * 6 * 4, imageData.data.length); - assert.equal(0, imageData.data[0]); - assert.equal(0, imageData.data[1]); - assert.equal(0, imageData.data[2]); - assert.equal(0, imageData.data[3]); + assert.equal(0, imageData.data[0]); + assert.equal(0, imageData.data[1]); + assert.equal(0, imageData.data[2]); + assert.equal(0, imageData.data[3]); + }); + + it("works, A8 format", function () { + var canvas = createCanvas(20, 20) + , ctx = canvas.getContext('2d', {pixelFormat: "A8"}); + + var imageData = ctx.createImageData(2,6); + assert.equal(2, imageData.width); + assert.equal(6, imageData.height); + assert.equal(2 * 6 * 1, imageData.data.length); + + assert.equal(0, imageData.data[0]); + assert.equal(0, imageData.data[1]); + assert.equal(0, imageData.data[2]); + assert.equal(0, imageData.data[3]); + }); + + it("works, A1 format", function () { + var canvas = createCanvas(20, 20) + , ctx = canvas.getContext('2d', {pixelFormat: "A1"}); + + var imageData = ctx.createImageData(2,6); + assert.equal(2, imageData.width); + assert.equal(6, imageData.height); + assert.equal(Math.ceil(2 * 6 / 8), imageData.data.length); + + assert.equal(0, imageData.data[0]); + assert.equal(0, imageData.data[1]); + }); + + it("works, RGB24 format", function () { + var canvas = createCanvas(20, 20) + , ctx = canvas.getContext('2d', {pixelFormat: "RGB24"}); + + var imageData = ctx.createImageData(2,6); + assert.equal(2, imageData.width); + assert.equal(6, imageData.height); + assert.equal(2 * 6 * 4, imageData.data.length); + + assert.equal(0, imageData.data[0]); + assert.equal(0, imageData.data[1]); + assert.equal(0, imageData.data[2]); + assert.equal(0, imageData.data[3]); + }); + + it("works, RGB16_565 format", function () { + var canvas = createCanvas(20, 20) + , ctx = canvas.getContext('2d', {pixelFormat: "RGB16_565"}); + + var imageData = ctx.createImageData(2,6); + assert(imageData.data instanceof Uint16Array); + assert.equal(2, imageData.width); + assert.equal(6, imageData.height); + assert.equal(2 * 6, imageData.data.length); + + assert.equal(0, imageData.data[0]); + assert.equal(0, imageData.data[1]); + }); }); it('Context2d#measureText().width', function () { @@ -639,64 +748,183 @@ describe('Canvas', function () { assert.equal(2 * 6 * 4, imageData.data.length); }); - it('Context2d#getImageData()', function () { - var canvas = createCanvas(3, 6) - , ctx = canvas.getContext('2d'); + describe('Context2d#getImageData()', function () { + function createTestCanvas(useAlpha, attributes) { + var canvas = createCanvas(3, 6); + var ctx = canvas.getContext('2d', attributes); - ctx.fillStyle = '#f00'; - ctx.fillRect(0,0,1,6); + ctx.fillStyle = useAlpha ? 'rgba(255,0,0,0.25)' : '#f00'; + ctx.fillRect(0,0,1,6); - ctx.fillStyle = '#0f0'; - ctx.fillRect(1,0,1,6); + ctx.fillStyle = useAlpha ? 'rgba(0,255,0,0.5)' : '#0f0'; + ctx.fillRect(1,0,1,6); - ctx.fillStyle = '#00f'; - ctx.fillRect(2,0,1,6); + ctx.fillStyle = useAlpha ? 'rgba(0,0,255,0.75)' : '#00f'; + ctx.fillRect(2,0,1,6); - // Full width - var imageData = ctx.getImageData(0,0,3,6); - assert.equal(3, imageData.width); - assert.equal(6, imageData.height); - assert.equal(3 * 6 * 4, imageData.data.length); + return ctx; + } - assert.equal(255, imageData.data[0]); - assert.equal(0, imageData.data[1]); - assert.equal(0, imageData.data[2]); - assert.equal(255, imageData.data[3]); + it("works, full width, RGBA32", function () { + var ctx = createTestCanvas(); + var imageData = ctx.getImageData(0,0,3,6); - assert.equal(0, imageData.data[4]); - assert.equal(255, imageData.data[5]); - assert.equal(0, imageData.data[6]); - assert.equal(255, imageData.data[7]); + assert.equal(3, imageData.width); + assert.equal(6, imageData.height); + assert.equal(3 * 6 * 4, imageData.data.length); - assert.equal(0, imageData.data[8]); - assert.equal(0, imageData.data[9]); - assert.equal(255, imageData.data[10]); - assert.equal(255, imageData.data[11]); + assert.equal(255, imageData.data[0]); + assert.equal(0, imageData.data[1]); + assert.equal(0, imageData.data[2]); + assert.equal(255, imageData.data[3]); - // Slice - var imageData = ctx.getImageData(0,0,2,1); - assert.equal(2, imageData.width); - assert.equal(1, imageData.height); - assert.equal(8, imageData.data.length); + assert.equal(0, imageData.data[4]); + assert.equal(255, imageData.data[5]); + assert.equal(0, imageData.data[6]); + assert.equal(255, imageData.data[7]); - assert.equal(255, imageData.data[0]); - assert.equal(0, imageData.data[1]); - assert.equal(0, imageData.data[2]); - assert.equal(255, imageData.data[3]); + assert.equal(0, imageData.data[8]); + assert.equal(0, imageData.data[9]); + assert.equal(255, imageData.data[10]); + assert.equal(255, imageData.data[11]); + }); - assert.equal(0, imageData.data[4]); - assert.equal(255, imageData.data[5]); - assert.equal(0, imageData.data[6]); - assert.equal(255, imageData.data[7]); + it("works, full width, RGB24", function () { + var ctx = createTestCanvas(false, {pixelFormat: "RGB24"}); + var imageData = ctx.getImageData(0,0,3,6); + assert.equal(3, imageData.width); + assert.equal(6, imageData.height); + assert.equal(3 * 6 * 4, imageData.data.length); + + assert.equal(255, imageData.data[0]); + assert.equal(0, imageData.data[1]); + assert.equal(0, imageData.data[2]); + assert.equal(255, imageData.data[3]); + + assert.equal(0, imageData.data[4]); + assert.equal(255, imageData.data[5]); + assert.equal(0, imageData.data[6]); + assert.equal(255, imageData.data[7]); + + assert.equal(0, imageData.data[8]); + assert.equal(0, imageData.data[9]); + assert.equal(255, imageData.data[10]); + assert.equal(255, imageData.data[11]); + }); + + it("works, full width, RGB16_565", function () { + var ctx = createTestCanvas(false, {pixelFormat: "RGB16_565"}); + var imageData = ctx.getImageData(0,0,3,6); + assert.equal(3, imageData.width); + assert.equal(6, imageData.height); + assert.equal(3 * 6 * 2, imageData.data.length); + + assert.equal((255 & 0b11111) << 11, imageData.data[0]); + assert.equal((255 & 0b111111) << 5, imageData.data[1]); + assert.equal((255 & 0b11111), imageData.data[2]); + + assert.equal((255 & 0b11111) << 11, imageData.data[3]); + assert.equal((255 & 0b111111) << 5, imageData.data[4]); + assert.equal((255 & 0b11111), imageData.data[5]); + }); + + it("works, full width, A8", function () { + var ctx = createTestCanvas(true, {pixelFormat: "A8"}); + var imageData = ctx.getImageData(0,0,3,6); + assert.equal(3, imageData.width); + assert.equal(6, imageData.height); + assert.equal(3 * 6, imageData.data.length); + + assert.equal(63, imageData.data[0]); + assert.equal(127, imageData.data[1]); + assert.equal(191, imageData.data[2]); + + assert.equal(63, imageData.data[3]); + assert.equal(127, imageData.data[4]); + assert.equal(191, imageData.data[5]); + }); + + it("works, full width, A1"); + + it("works, full width, RGB30"); + + it("works, slice, RGBA32", function () { + var ctx = createTestCanvas(); + var imageData = ctx.getImageData(0,0,2,1); + assert.equal(2, imageData.width); + assert.equal(1, imageData.height); + assert.equal(8, imageData.data.length); + + assert.equal(255, imageData.data[0]); + assert.equal(0, imageData.data[1]); + assert.equal(0, imageData.data[2]); + assert.equal(255, imageData.data[3]); + + assert.equal(0, imageData.data[4]); + assert.equal(255, imageData.data[5]); + assert.equal(0, imageData.data[6]); + assert.equal(255, imageData.data[7]); + }); - // Assignment - var data = ctx.getImageData(0,0,5,5).data; - data[0] = 50; - assert.equal(50, data[0]); - data[0] = 280; - assert.equal(255, data[0]); - data[0] = -4444; - assert.equal(0, data[0]); + it("works, slice, RGB24", function () { + var ctx = createTestCanvas(false, {pixelFormat: "RGB24"}); + var imageData = ctx.getImageData(0,0,2,1); + assert.equal(2, imageData.width); + assert.equal(1, imageData.height); + assert.equal(8, imageData.data.length); + + assert.equal(255, imageData.data[0]); + assert.equal(0, imageData.data[1]); + assert.equal(0, imageData.data[2]); + assert.equal(255, imageData.data[3]); + + assert.equal(0, imageData.data[4]); + assert.equal(255, imageData.data[5]); + assert.equal(0, imageData.data[6]); + assert.equal(255, imageData.data[7]); + }); + + it("works, slice, RGB16_565", function () { + var ctx = createTestCanvas(false, {pixelFormat: "RGB16_565"}); + var imageData = ctx.getImageData(0,0,2,1); + assert.equal(2, imageData.width); + assert.equal(1, imageData.height); + assert.equal(2 * 1 * 2, imageData.data.length); + + assert.equal((255 & 0b11111) << 11, imageData.data[0]); + assert.equal((255 & 0b111111) << 5, imageData.data[1]); + }); + + it("works, slice, A8", function () { + var ctx = createTestCanvas(true, {pixelFormat: "A8"}); + var imageData = ctx.getImageData(0,0,2,1); + assert.equal(2, imageData.width); + assert.equal(1, imageData.height); + assert.equal(2 * 1, imageData.data.length); + + assert.equal(63, imageData.data[0]); + assert.equal(127, imageData.data[1]); + }); + + it("works, slice, A1"); + + it("works, slice, RGB30"); + + it("works, assignment", function () { + var ctx = createTestCanvas(); + var data = ctx.getImageData(0,0,5,5).data; + data[0] = 50; + assert.equal(50, data[0]); + data[0] = 280; + assert.equal(255, data[0]); + data[0] = -4444; + assert.equal(0, data[0]); + }); + + it("throws if indexes are invalid", function () { + var ctx = createTestCanvas(); + assert.throws(function () { ctx.getImageData(0, 0, 0, 0); }, /IndexSizeError/); + }); }); it('Context2d#createPattern(Canvas)', function () { @@ -834,42 +1062,77 @@ describe('Canvas', function () { assert.equal(255, imageData.data[i+3]); }); - it('Context2d#getImageData()', function () { - var canvas = createCanvas(1, 1) - , ctx = canvas.getContext('2d'); + describe('Context2d#putImageData()', function () { + it('throws for invalid arguments', function () { + var canvas = createCanvas(2, 1); + var ctx = canvas.getContext('2d'); + assert.throws(function () { ctx.putImageData({}, 0, 0); }, TypeError); + assert.throws(function () { ctx.putImageData(undefined, 0, 0); }, TypeError); + }); - assert.throws(function () { ctx.getImageData(0, 0, 0, 0); }, /IndexSizeError/); + it('works, RGBA32', function () { + var canvas = createCanvas(2, 1); + var ctx = canvas.getContext('2d'); + ctx.fillStyle = '#f00'; + ctx.fillRect(0, 0, 1, 1); - ctx.fillStyle = '#f00'; - ctx.fillRect(0, 0, 1, 1); + // Copy left pixel to the right pixel + ctx.putImageData(ctx.getImageData(0, 0, 1, 1), 1, 0); - var pixel = ctx.getImageData(0, 0, 1, 1); + var pixel = ctx.getImageData(1, 0, 1, 1); - assert.equal(pixel.data[0], 255); - assert.equal(pixel.data[1], 0); - assert.equal(pixel.data[2], 0); - assert.equal(pixel.data[3], 255); - }); + assert.equal(pixel.data[0], 255); + assert.equal(pixel.data[1], 0); + assert.equal(pixel.data[2], 0); + assert.equal(pixel.data[3], 255); + }); - it('Context2d#putImageData()', function () { - var canvas = createCanvas(2, 1) - , ctx = canvas.getContext('2d'); + it('works, RGB24/alpha:false', function () { + var canvas = createCanvas(2, 1); + var ctx = canvas.getContext('2d', {pixelFormat: 'RGB24'}); + ctx.fillStyle = '#f00'; + ctx.fillRect(0, 0, 1, 1); - assert.throws(function () { ctx.putImageData({}, 0, 0); }, TypeError); - assert.throws(function () { ctx.putImageData(undefined, 0, 0); }, TypeError); + // Copy left pixel to the right pixel + ctx.putImageData(ctx.getImageData(0, 0, 1, 1), 1, 0); + + var pixel = ctx.getImageData(1, 0, 1, 1); + + assert.equal(pixel.data[0], 255); + assert.equal(pixel.data[1], 0); + assert.equal(pixel.data[2], 0); + assert.equal(pixel.data[3], 255); + }); - ctx.fillStyle = '#f00'; - ctx.fillRect(0, 0, 1, 1); + it('works, A8', function () { + var canvas = createCanvas(2, 1); + var ctx = canvas.getContext('2d', {pixelFormat: 'A8'}); - // Copy left pixel to the right pixel - ctx.putImageData(ctx.getImageData(0, 0, 1, 1), 1, 0); + var imgData = ctx.getImageData(0, 0, 2, 1); + imgData.data[0] = 4; + imgData.data[1] = 21; + ctx.putImageData(imgData, 0, 0); - var pixel = ctx.getImageData(1, 0, 1, 1); + var pixel = ctx.getImageData(0, 0, 2, 1); - assert.equal(pixel.data[0], 255); - assert.equal(pixel.data[1], 0); - assert.equal(pixel.data[2], 0); - assert.equal(pixel.data[3], 255); + assert.equal(pixel.data[0], 4); + assert.equal(pixel.data[1], 21); + }); + + it('works, RGB16_565', function () { + var canvas = createCanvas(2, 1); + var ctx = canvas.getContext('2d', {pixelFormat: 'RGB16_565'}); + + var imgData = ctx.getImageData(0, 0, 2, 1); + imgData.data[0] = 65535; // 2**16 - 1 + imgData.data[1] = 65500; + ctx.putImageData(imgData, 0, 0); + + var pixel = ctx.getImageData(0, 0, 2, 1); + + assert.equal(pixel.data[0], 65535); + assert.equal(pixel.data[1], 65500); + }); }); it('Canvas#createSyncPNGStream()', function (done) { diff --git a/test/imageData.test.js b/test/imageData.test.js index b78fadcf1..30e60bbcd 100644 --- a/test/imageData.test.js +++ b/test/imageData.test.js @@ -25,26 +25,42 @@ describe('ImageData', function () { it('should throw with invalid typed array', function () { assert.throws(() => { createImageData(new Uint8ClampedArray(0), 0) }, /input data has a zero byte length/) - assert.throws(() => { createImageData(new Uint8ClampedArray(3), 0) }, /input data byte length is not a multiple of 4/) - assert.throws(() => { createImageData(new Uint8ClampedArray(16), 3) }, RangeError) - assert.throws(() => { createImageData(new Uint8ClampedArray(12), 3, 5) }, RangeError) - }) + assert.throws(() => { createImageData(new Uint8ClampedArray(3), 0) }, /source width is zero/) + // Note: Some errors thrown by browsers are not thrown by node-canvas + // because our ImageData can support different BPPs. + }); - it('should construct with typed array', function () { + it('should construct with Uint8ClampedArray', function () { let data, imageData data = new Uint8ClampedArray(2 * 3 * 4) imageData = createImageData(data, 2) assert.strictEqual(imageData.width, 2) assert.strictEqual(imageData.height, 3) - assert.ok(imageData.data instanceof Uint8ClampedArray) + assert(imageData.data instanceof Uint8ClampedArray) assert.strictEqual(imageData.data.length, 24) data = new Uint8ClampedArray(3 * 4 * 4) imageData = createImageData(data, 3, 4) assert.strictEqual(imageData.width, 3) assert.strictEqual(imageData.height, 4) - assert.ok(imageData.data instanceof Uint8ClampedArray) + assert(imageData.data instanceof Uint8ClampedArray) assert.strictEqual(imageData.data.length, 48) - }) -}) + }); + + it('should construct with Uint16Array', function () { + let data = new Uint16Array(2 * 3 * 2) + let imagedata = createImageData(data, 2) + assert.strictEqual(imagedata.width, 2) + assert.strictEqual(imagedata.height, 3) + assert(imagedata.data instanceof Uint16Array) + assert.strictEqual(imagedata.data.length, 12) + + data = new Uint16Array(3 * 4 * 2) + imagedata = createImageData(data, 3, 4) + assert.strictEqual(imagedata.width, 3) + assert.strictEqual(imagedata.height, 4) + assert(imagedata.data instanceof Uint16Array) + assert.strictEqual(imagedata.data.length, 24) + }); +}); diff --git a/test/public/app.js b/test/public/app.js index ff53f4a5c..e706f2c72 100644 --- a/test/public/app.js +++ b/test/public/app.js @@ -21,7 +21,12 @@ function pdfLink (name) { function localRendering (name) { var canvas = create('canvas', { width: 200, height: 200, title: name }) - window.tests[name](canvas.getContext('2d'), function () {}) + var ctx = canvas.getContext('2d', {alpha: true}) + var initialFillStyle = ctx.fillStyle + ctx.fillStyle = 'white' + ctx.fillRect(0, 0, 200, 200) + ctx.fillStyle = initialFillStyle + window.tests[name](ctx, function () {}) return canvas } diff --git a/test/public/tests.js b/test/public/tests.js index 68170c3fe..58f656b3c 100644 --- a/test/public/tests.js +++ b/test/public/tests.js @@ -1912,8 +1912,10 @@ tests['putImageData() png data'] = function (ctx, done) { ctx.drawImage(img, 0, 0, 200, 200) var imageData = ctx.getImageData(0, 0, 50, 50) var data = imageData.data - for (var i = 0, len = data.length; i < len; i += 4) { - data[i + 3] = 80 + if (data instanceof Uint8ClampedArray) { + for (var i = 0, len = data.length; i < len; i += 4) { + data[i + 3] = 80 + } } ctx.putImageData(imageData, 50, 50) done(null) @@ -1933,8 +1935,10 @@ tests['putImageData() png data 2'] = function (ctx, done) { ctx.drawImage(img, 0, 0, 200, 200) var imageData = ctx.getImageData(0, 0, 50, 50) var data = imageData.data - for (var i = 0, len = data.length; i < len; i += 4) { - data[i + 3] = 80 + if (data instanceof Uint8ClampedArray) { + for (var i = 0, len = data.length; i < len; i += 4) { + data[i + 3] = 80 + } } ctx.putImageData(imageData, 50, 50, 10, 10, 20, 20) done(null) @@ -1954,10 +1958,12 @@ tests['putImageData() png data 3'] = function (ctx, done) { ctx.drawImage(img, 0, 0, 200, 200) var imageData = ctx.getImageData(0, 0, 50, 50) var data = imageData.data - for (var i = 0, len = data.length; i < len; i += 4) { - data[i + 0] = data[i + 0] * 0.2 - data[i + 1] = data[i + 1] * 0.2 - data[i + 2] = data[i + 2] * 0.2 + if (data instanceof Uint8ClampedArray) { + for (var i = 0, len = data.length; i < len; i += 4) { + data[i + 0] = data[i + 0] * 0.2 + data[i + 1] = data[i + 1] * 0.2 + data[i + 2] = data[i + 2] * 0.2 + } } ctx.putImageData(imageData, 50, 50) done(null) diff --git a/test/server.js b/test/server.js index 04207becb..e1378c9bf 100644 --- a/test/server.js +++ b/test/server.js @@ -12,10 +12,15 @@ function renderTest (canvas, name, cb) { throw new Error('Unknown test: ' + name) } + var ctx = canvas.getContext('2d', {pixelFormat: 'RGBA32'}) + var initialFillStyle = ctx.fillStyle + ctx.fillStyle = 'white' + ctx.fillRect(0, 0, 200, 200) + ctx.fillStyle = initialFillStyle if (tests[name].length === 2) { - tests[name](canvas.getContext('2d'), cb) + tests[name](ctx, cb) } else { - tests[name](canvas.getContext('2d')) + tests[name](ctx) cb(null) } }