Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support canvas.getContext("2d", {alpha: boolean, pixelFormat: string}) #935

Merged
merged 4 commits into from
Jul 15, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading