Skip to content

Commit

Permalink
Indexed PNG encoding
Browse files Browse the repository at this point in the history
  • Loading branch information
zbjornson committed Jul 2, 2017
1 parent 18c05f1 commit 16de515
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 18 deletions.
1 change: 1 addition & 0 deletions History.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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
24 changes: 21 additions & 3 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,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 @@ -131,6 +131,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 @@ -331,7 +347,9 @@ These additional pixel formats have experimental support:
`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).
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
Expand Down Expand Up @@ -363,7 +381,7 @@ Notes and caveats:

* `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!
us know! (See #935.)

* `A1`, `A8`, `RGB30` and `RGB16_565` with shadow blurs may crash or not render
properly.
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')))
18 changes: 14 additions & 4 deletions lib/canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,25 +130,35 @@ Canvas.prototype.getContext = function (contextType, contextAttributes) {
/**
* 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
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);
};
42 changes: 38 additions & 4 deletions src/Canvas.cc
Original file line number Diff line number Diff line change
Expand Up @@ -359,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 @@ -368,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 @@ -384,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 @@ -407,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
42 changes: 37 additions & 5 deletions src/PNG.h
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,13 @@ 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);
// FIXME why is this not typed properly?
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);

switch (cairo_image_surface_get_format(surface)) {
cairo_format_t format = cairo_image_surface_get_format(surface);

switch (format) {
case CAIRO_FORMAT_ARGB32:
bpc = 8;
png_color_type = PNG_COLOR_TYPE_RGB_ALPHA;
Expand Down Expand Up @@ -197,11 +200,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_t *) ((canvas_png_write_closure_t *) 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_t *) ((canvas_png_write_closure_t *) closure)->closure)->nPaletteColors;
uint8_t* colors = ((closure_t *) ((canvas_png_write_closure_t *) closure)->closure)->palette;
uint8_t backgroundIndex = ((closure_t *) ((canvas_png_write_closure_t *) 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'
Expand All @@ -210,7 +242,7 @@ 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 (cairo_image_surface_get_format(surface) == CAIRO_FORMAT_RGB16_565) {
} 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);
Expand Down
3 changes: 3 additions & 0 deletions src/closure.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/*
Expand Down

0 comments on commit 16de515

Please sign in to comment.