Skip to content

Commit

Permalink
Merge pull request #935 from zbjornson/formats
Browse files Browse the repository at this point in the history
Support canvas.getContext("2d", {alpha: boolean, pixelFormat: string})
  • Loading branch information
LinusU committed Jul 15, 2017
2 parents 41af8c0 + 8c41bb1 commit 25b03f3
Show file tree
Hide file tree
Showing 23 changed files with 1,052 additions and 220 deletions.
3 changes: 3 additions & 0 deletions History.md
Expand Up @@ -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
==================
Expand Down
88 changes: 87 additions & 1 deletion Readme.md
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions 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')))
39 changes: 39 additions & 0 deletions 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')))
27 changes: 19 additions & 8 deletions lib/canvas.js
Expand Up @@ -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;
Expand All @@ -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);
};

/**
Expand Down
11 changes: 9 additions & 2 deletions lib/context2d.js
Expand Up @@ -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);
}
10 changes: 8 additions & 2 deletions lib/pngstream.js
Expand Up @@ -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'");
}
Expand All @@ -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';
Expand All @@ -66,5 +72,5 @@ PNGStream.prototype._read = function _read() {
} else {
self.push(null);
}
});
}, self.options);
};
45 changes: 41 additions & 4 deletions src/Canvas.cc
Expand Up @@ -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<Backend>(info[0]->ToObject());
}
else {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -365,6 +372,11 @@ NAN_METHOD(Canvas::StreamPNGSync) {
if (!info[0]->IsFunction())
return Nan::ThrowTypeError("callback function required");

Canvas *canvas = Nan::ObjectWrap::Unwrap<Canvas>(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;
Expand All @@ -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<Object> attrs = info[1]->ToObject();
Local<Value> palette = attrs->Get(Nan::New("palette").ToLocalChecked());
if (palette->IsUint8ClampedArray()) {
Local<Uint8ClampedArray> palette_ta = palette.As<Uint8ClampedArray>();
nPaletteColors = palette_ta->Length();
if (nPaletteColors % 4 != 0) {
Nan::ThrowError("Palette length must be a multiple of 4.");
}
nPaletteColors /= 4;
Nan::TypedArrayContents<uint8_t> _paletteColors(palette_ta);
paletteColors = *_paletteColors;
// Optional background color index:
Local<Value> backgroundIndexVal = attrs->Get(Nan::New("backgroundIndex").ToLocalChecked());
if (backgroundIndexVal->IsUint32()) {
backgroundIndex = static_cast<uint8_t>(backgroundIndexVal->Uint32Value());
}
}
}
} else {
good = false;
}

if (good) {
if (compression_level > 9) {
Expand All @@ -404,11 +439,13 @@ NAN_METHOD(Canvas::StreamPNGSync) {
}


Canvas *canvas = Nan::ObjectWrap::Unwrap<Canvas>(info.This());
closure_t closure;
closure.fn = Local<Function>::Cast(info[0]);
closure.compression_level = compression_level;
closure.filter = filter;
closure.palette = paletteColors;
closure.nPaletteColors = nPaletteColors;
closure.backgroundIndex = backgroundIndex;

Nan::TryCatch try_catch;

Expand Down

0 comments on commit 25b03f3

Please sign in to comment.