sixel
Version:
Sixel image format for node and browser.
472 lines • 18.9 kB
JavaScript
"use strict";
/**
* Copyright (c) 2021 Joerg Breitbart.
* @license MIT
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;
const Colors_1 = require("./Colors");
const wasm_1 = require("./wasm");
/* istanbul ignore next */
function decodeBase64(s) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(s, 'base64');
}
const bytestring = atob(s);
const result = new Uint8Array(bytestring.length);
for (let i = 0; i < result.length; ++i) {
result[i] = bytestring.charCodeAt(i);
}
return result;
}
const WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);
let WASM_MODULE;
// empty canvas
const NULL_CANVAS = new Uint32Array();
// proxy for lazy binding of decoder methods to wasm env callbacks
class CallbackProxy {
constructor() {
this.bandHandler = (width) => 1;
this.modeHandler = (mode) => 1;
}
handle_band(width) {
return this.bandHandler(width);
}
mode_parsed(mode) {
return this.modeHandler(mode);
}
}
// default decoder options
const DEFAULT_OPTIONS = {
memoryLimit: 2048 * 65536,
sixelColor: Colors_1.DEFAULT_FOREGROUND,
fillColor: Colors_1.DEFAULT_BACKGROUND,
palette: Colors_1.PALETTE_VT340_COLOR,
paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,
truncate: true
};
/**
* Create a decoder instance asynchronously.
* To be used in the browser main thread.
*/
function DecoderAsync(opts) {
const cbProxy = new CallbackProxy();
const importObj = {
env: {
handle_band: cbProxy.handle_band.bind(cbProxy),
mode_parsed: cbProxy.mode_parsed.bind(cbProxy)
}
};
return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)
.then((inst) => {
WASM_MODULE = WASM_MODULE || inst.module;
return new Decoder(opts, inst.instance || inst, cbProxy);
});
}
exports.DecoderAsync = DecoderAsync;
/**
* Decoder - web assembly based sixel stream decoder.
*
* Usage pattern:
* - call `init` to initialize decoder for new image
* - feed data chunks to `decode` or `decodeString`
* - grab pixels from `data32`
* - optional: call `release` to free memory (e.g. after big images)
* - start over with next image by calling `init`
*
* Properties:
* - max width of 2^14 - 4 pixels (compile time setting in wasm)
* - no explicit height limit (only limited by memory)
* - max 4096 colors palette (compile time setting in wasm)
*
* Explanation operation modes:
* - M1 Mode chosen for level 1 images (no raster attributes),
* or for level 2 images with `truncate=false`.
* - M2 Mode chosen for level 2 images with `truncate=true` (default).
* While this mode is not fully spec conform (decoder not expected to truncate),
* it is what spec conform encoders should create (should not excess raster).
* This mode has several advantages:
* - ~15% faster decoding speed
* - image dimensions can be evaluated early without processing the whole data
* - faster pixel access in `data32` (precalulated)
* - image height is not reported as multiple of 6 pixels
* - M0 Undecided mode state after `init`.
* The level of an image is determined during early decoding based on the fact,
* whether the data contains valid raster attributes before any sixel data.
* Until then the mode of an image is marked as M0, meaning the real operation mode
* could not be decided yet.
*/
class Decoder {
/**
* Synchonous ctor. Can be called from nodejs or a webworker context.
* For instantiation in the browser main thread use `WasmDecoderAsync` instead.
*/
constructor(opts, _instance, _cbProxy) {
this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;
this._canvas = NULL_CANVAS;
this._bandWidths = [];
this._maxWidth = 0;
this._minWidth = wasm_1.LIMITS.MAX_WIDTH;
this._lastOffset = 0;
this._currentHeight = 0;
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {
throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);
}
if (!_instance) {
const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));
_instance = new WebAssembly.Instance(module, {
env: {
handle_band: this._handle_band.bind(this),
mode_parsed: this._initCanvas.bind(this)
}
});
}
else {
_cbProxy.bandHandler = this._handle_band.bind(this);
_cbProxy.modeHandler = this._initCanvas.bind(this);
}
this._instance = _instance;
this._wasm = this._instance.exports;
this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);
this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);
this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);
this._palette.set(this._opts.palette);
this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());
this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);
}
// some readonly parser states for internal usage
get _fillColor() { return this._states[0]; }
get _truncate() { return this._states[8]; }
get _rasterWidth() { return this._states[6]; }
get _rasterHeight() { return this._states[7]; }
get _width() { return this._states[2] ? this._states[2] - 4 : 0; }
get _height() { return this._states[3]; }
get _level() { return this._states[9]; }
get _mode() { return this._states[10]; }
get _paletteLimit() { return this._states[11]; }
_initCanvas(mode) {
if (mode === 2 /* M2 */) {
const pixels = this.width * this.height;
if (pixels > this._canvas.length) {
if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {
this.release();
throw new Error('image exceeds memory limit');
}
this._canvas = new Uint32Array(pixels);
}
this._maxWidth = this._width;
}
else if (mode === 1 /* M1 */) {
if (this._level === 2) {
// got raster attributes, use them as initial size hint
const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;
if (pixels > this._canvas.length) {
if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {
this.release();
throw new Error('image exceeds memory limit');
}
this._canvas = new Uint32Array(pixels);
}
}
else {
// else fallback to generic resizing, starting with 256*256 pixels
if (this._canvas.length < 65536) {
this._canvas = new Uint32Array(65536);
}
}
}
return 0; // 0 - continue, 1 - abort right away
}
_realloc(offset, additionalPixels) {
const pixels = offset + additionalPixels;
if (pixels > this._canvas.length) {
if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {
this.release();
throw new Error('image exceeds memory limit');
}
// extend in 65536 pixel blocks
const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);
newCanvas.set(this._canvas);
this._canvas = newCanvas;
}
}
_handle_band(width) {
const adv = this._PIXEL_OFFSET;
let offset = this._lastOffset;
if (this._mode === 2 /* M2 */) {
let remaining = this.height - this._currentHeight;
let c = 0;
while (c < 6 && remaining > 0) {
this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);
c++;
remaining--;
}
this._lastOffset += width * c;
this._currentHeight += c;
}
else if (this._mode === 1 /* M1 */) {
this._realloc(offset, width * 6);
this._maxWidth = Math.max(this._maxWidth, width);
this._minWidth = Math.min(this._minWidth, width);
for (let i = 0; i < 6; ++i) {
this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);
}
this._bandWidths.push(width);
this._lastOffset += width * 6;
this._currentHeight += 6;
}
return 0; // 0 - continue, 1 - abort right away
}
/**
* Width of the image data.
* Returns the rasterWidth in level2/truncating mode,
* otherwise the max width, that has been seen so far.
*/
get width() {
return this._mode !== 1 /* M1 */
? this._width
: Math.max(this._maxWidth, this._wasm.current_width());
}
/**
* Height of the image data.
* Returns the rasterHeight in level2/truncating mode,
* otherwise height touched by sixels.
*/
get height() {
return this._mode !== 1 /* M1 */
? this._height
: this._wasm.current_width()
? this._bandWidths.length * 6 + this._wasm.current_height()
: this._bandWidths.length * 6;
}
/**
* Get active palette colors as RGBA8888[] (borrowed).
*/
get palette() {
return this._palette.subarray(0, this._paletteLimit);
}
/**
* Get the memory used by the decoder.
*
* This is a rough estimate accounting the wasm instance memory
* and pixel buffers held on JS side (real value will be slightly
* higher due to JS book-keeping).
* Note that the decoder does not free ressources on its own,
* call `release` to free excess memory.
*/
get memoryUsage() {
return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;
}
/**
* Get various properties of the decoder and the current image.
*/
get properties() {
return {
width: this.width,
height: this.height,
mode: this._mode,
level: this._level,
truncate: !!this._truncate,
paletteLimit: this._paletteLimit,
fillColor: this._fillColor,
memUsage: this.memoryUsage,
rasterAttributes: {
numerator: this._states[4],
denominator: this._states[5],
width: this._rasterWidth,
height: this._rasterHeight,
}
};
}
/**
* Initialize decoder for next image. Must be called before
* any calls to `decode` or `decodeString`.
*/
// FIXME: reorder arguments, better palette handling
init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {
this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);
if (palette) {
this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));
}
this._bandWidths.length = 0;
this._maxWidth = 0;
this._minWidth = wasm_1.LIMITS.MAX_WIDTH;
this._lastOffset = 0;
this._currentHeight = 0;
}
/**
* Decode next chunk of data from start to end index (exclusive).
* @throws Will throw if the image exceeds the memory limit.
*/
decode(data, start = 0, end = data.length) {
let p = start;
while (p < end) {
const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);
this._chunk.set(data.subarray(p, p += length));
this._wasm.decode(0, length);
}
}
/**
* Decode next chunk of string data from start to end index (exclusive).
* Note: Decoding from string data is rather slow, use `decode` with byte data instead.
* @throws Will throw if the image exceeds the memory limit.
*/
decodeString(data, start = 0, end = data.length) {
let p = start;
while (p < end) {
const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);
for (let i = 0, j = p; i < length; ++i, ++j) {
this._chunk[i] = data.charCodeAt(j);
}
p += length;
this._wasm.decode(0, length);
}
}
/**
* Get current pixel data as 32-bit typed array (RGBA8888).
* Also peeks into pixel data of the current band, that got not pushed yet.
*/
get data32() {
if (this._mode === 0 /* M0 */ || !this.width || !this.height) {
return NULL_CANVAS;
}
// get width of pending band to peek into left-over data
const currentWidth = this._wasm.current_width();
if (this._mode === 2 /* M2 */) {
let remaining = this.height - this._currentHeight;
if (remaining > 0) {
const adv = this._PIXEL_OFFSET;
let offset = this._lastOffset;
let c = 0;
while (c < 6 && remaining > 0) {
this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);
c++;
remaining--;
}
if (remaining) {
this._canvas.fill(this._fillColor, offset + currentWidth * c);
}
}
return this._canvas.subarray(0, this.width * this.height);
}
if (this._mode === 1 /* M1 */) {
if (this._minWidth === this._maxWidth) {
let escape = false;
if (currentWidth) {
if (currentWidth !== this._minWidth) {
escape = true;
}
else {
const adv = this._PIXEL_OFFSET;
let offset = this._lastOffset;
this._realloc(offset, currentWidth * 6);
for (let i = 0; i < 6; ++i) {
this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);
}
}
}
if (!escape) {
return this._canvas.subarray(0, this.width * this.height);
}
}
// worst case: re-align pixels if we have bands with different width
// This is somewhat allocation intensive, any way to do that in-place, and just once?
const final = new Uint32Array(this.width * this.height);
final.fill(this._fillColor);
let finalOffset = 0;
let start = 0;
for (let i = 0; i < this._bandWidths.length; ++i) {
const bw = this._bandWidths[i];
for (let p = 0; p < 6; ++p) {
final.set(this._canvas.subarray(start, start += bw), finalOffset);
finalOffset += this.width;
}
}
// also handle left-over pixels of the current band
if (currentWidth) {
const adv = this._PIXEL_OFFSET;
// other than finished bands, this runs only up to currentHeight
const currentHeight = this._wasm.current_height();
for (let i = 0; i < currentHeight; ++i) {
final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);
}
}
return final;
}
// fallthrough for all not handled cases
return NULL_CANVAS;
}
/**
* Same as `data32`, but returning pixel data as Uint8ClampedArray suitable
* for direct usage with `ImageData`.
*/
get data8() {
return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);
}
/**
* Release image ressources on JS side held by the decoder.
*
* The decoder tries to re-use memory ressources of a previous image
* to lower allocation and GC pressure. Decoding a single big image
* will grow the memory usage of the decoder permanently.
* Call `release` to reset the internal buffers and free the memory.
* Note that this destroys the image data, call it when done processing
* a rather big image, otherwise it is not needed. Use `memoryUsage`
* to decide, whether the held memory is still within your limits.
* This does not affect the wasm module (operates on static memory).
*/
release() {
this._canvas = NULL_CANVAS;
this._bandWidths.length = 0;
this._maxWidth = 0;
this._minWidth = wasm_1.LIMITS.MAX_WIDTH;
// also nullify parser states in wasm to avoid
// width/height reporting potential out-of-bound values
this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);
}
}
exports.Decoder = Decoder;
/**
* Convenient decoding functions for easier usage.
*
* These can be used for casual decoding of sixel images,
* that dont come in as stream chunks.
* Note that the functions instantiate a stream decoder for every call,
* which comes with a performance penalty of ~25%.
*/
/**
* Decode function with synchronous wasm loading.
* Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.
* @throws Will throw if the image exceeds the memory limit.
*/
function decode(data, opts) {
const dec = new Decoder(opts);
dec.init();
typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);
return {
width: dec.width,
height: dec.height,
data32: dec.data32,
data8: dec.data8
};
}
exports.decode = decode;
/**
* Decode function with asynchronous wasm loading.
* Use this version in normal browser context.
* @throws Will throw if the image exceeds the memory limit.
*/
async function decodeAsync(data, opts) {
const dec = await DecoderAsync(opts);
dec.init();
typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);
return {
width: dec.width,
height: dec.height,
data32: dec.data32,
data8: dec.data8
};
}
exports.decodeAsync = decodeAsync;
//# sourceMappingURL=Decoder.js.map