UNPKG

sixel

Version:

Sixel image format for node and browser.

361 lines 14.7 kB
"use strict"; /** * Copyright (c) 2019 Joerg Breitbart. * @license MIT */ Object.defineProperty(exports, "__esModule", { value: true }); exports.image2sixel = exports.sixelEncodeIndexed = exports.sixelEncode = exports.FINALIZER = exports.introducer = void 0; const Colors_1 = require("./Colors"); const Quantizer_1 = require("./Quantizer"); /** * Create escape sequence introducer for SIXEL. * Should be written to the terminal before any SIXEL data. * * A SIXEL DSC sequence understands 3 parameters, but only the second one (background select) is supported * by some terminals. Therefore only this parameter is exposed. * * backgroundSelect: * - 0 device default action (most terminals will apply background color) * - 1 no action (no change to zero bit value grid positions) * - 2 set to background color - zero bit value grid positions are set to background color (device dependent). * * @see https://www.vt100.net/docs/vt3xx-gp/chapter14.html * @param backgroundSelect background color setting (default = 0) */ function introducer(backgroundSelect = 0) { return `\x1bP0;${backgroundSelect};q`; } exports.introducer = introducer; /** * Finalize SIXEL sequence. Write this, when the SIXEL data stream has ended to restore * the terminal to normal operation. */ exports.FINALIZER = '\x1b\\'; /** * Convert 6 bit code to SIXEL string. */ function codeToSixel(code, repeat) { const c = String.fromCharCode(code + 63); if (repeat > 3) return '!' + repeat + c; if (repeat === 3) return c + c + c; if (repeat === 2) return c + c; return c; } /** * Create SIXEL data for a 6 pixel band. */ function processBand(data32, start, bandHeight, width, colorMap, paletteRGB) { // temp buffers to hold various color data // last: last seen SIXEL code per color // code: current SIXEL code per color // accu: count rows with equal SIXEL codes per color // slots: palette color --> idx in usedColorIdx const last = new Int8Array(paletteRGB.length + 1); const code = new Uint8Array(paletteRGB.length + 1); const accu = new Uint16Array(paletteRGB.length + 1); const slots = new Int16Array(paletteRGB.length + 1); last.fill(-1); accu.fill(1); slots.fill(-1); // array to hold band local color idx // only those are processed and written to output // whenever a new color enters here we have to extend the accu/code handling below const usedColorIdx = []; // storage for SIXELs per color in band const targets = []; let oldColor = 0; let idx = 0; for (let i = 0; i < width; ++i) { const p = start + i; let rowOffset = 0; code.fill(0, 0, usedColorIdx.length); for (let row = 0; row < bandHeight; ++row) { const color = data32[p + rowOffset]; // skip expensive color to palette matching if we have same color as before if (color !== oldColor) { oldColor = color; idx = (0, Colors_1.alpha)(color) ? colorMap.get(color) || 0 : 0; if (idx === undefined) { idx = (0, Colors_1.nearestColorIndex)(color, paletteRGB) + 1; colorMap.set(color, idx); } // extend accu/code handling to new color if (slots[idx] === -1) { targets.push([]); // if not at start catch up by writing 0s up to i for new color // (happens during shift below) if (i) { last[usedColorIdx.length] = 0; accu[usedColorIdx.length] = i; } slots[idx] = usedColorIdx.length; usedColorIdx.push(idx); } } // update codes for a row of 6 pixels code[slots[idx]] |= 1 << row; rowOffset += width; } // code/last/accu shift, updates SIXELs per color in band for (let j = 0; j < usedColorIdx.length; ++j) { if (code[j] === last[j]) { accu[j]++; } else { if (~last[j]) { targets[j].push(codeToSixel(last[j], accu[j])); } last[j] = code[j]; accu[j] = 1; } } } // handle remaining SIXELs to EOL for (let j = 0; j < usedColorIdx.length; ++j) { if (last[j]) { targets[j].push(codeToSixel(last[j], accu[j])); } } // write sixel chunk for every color in band const result = []; for (let j = 0; j < usedColorIdx.length; ++j) { if (!usedColorIdx[j]) continue; // skip background result.push('#' + (usedColorIdx[j] - 1) + targets[j].join('') + '$'); } return result.join(''); } /** * sixelEncode - encode pixel data to SIXEL string. * * The colors of the image get aligned to the given palette, unmatched colors will be translated * by euclidean distance. Without proper quantization beforehand this leads to poor output quality, * thus consider using a quantizer with custom palette creation and dithering. * For transparency only an alpha value of 0 will be respected as fully transparent, * other alpha values are set to fully opaque (255). Transparent pixels will be colored by the * terminal later on depending on the `backgroundSelect` setting of the introducer. * * To be in line with the SIXEL spec (DEC STD 070) `palette` should not contain more than 256 colors. * Note that older devices limit color registers even further (16 on VT340). Furthermore a high * number of colors will have a penalty on creation time, temporary memory usage and * the size of the SIXEL data. For simple graphics a rather small palette (16 to 64) might do, * for complicated pictures higher should work with 128+. * * @param data pixel data * @param width width of the image * @param height height of the image * @param palette palette to be applied * @param rasterAttributes whether to write raster attributes (true) */ function sixelEncode(data, width, height, palette, rasterAttributes = true) { // some sanity checks if (!data.length || !width || !height) { return ''; } if (width * height * 4 !== data.length) { throw new Error('wrong geometry of data'); } if (!palette || !palette.length) { throw new Error('palette must not be empty'); } // cleanup/prepare palettes // paletteWithZero: holds background color in slot 0 // paletteRGB: list of [R, G, B] for ED calc const paletteWithZero = [0]; const paletteRGB = []; for (let i = 0; i < palette.length; ++i) { let color = palette[i]; if (typeof color === 'number') { if (!(0, Colors_1.alpha)(color)) continue; color = (0, Colors_1.toRGBA8888)(...(0, Colors_1.fromRGBA8888)(color)); } else { color = (0, Colors_1.toRGBA8888)(...color); } if (!~paletteWithZero.indexOf(color)) { paletteWithZero.push(color); paletteRGB.push((0, Colors_1.fromRGBA8888)(color).slice(0, -1)); } } // SIXEL data storage const chunks = []; // write raster attributes (includes image dimensions) - " Pan ; Pad ; Ph ; Pv // note: Pan/Pad are set to dummies (not eval'd by any terminal) if (rasterAttributes) { chunks.push(`"1;1;${width};${height}`); } // create palette and write color entries for (let [idx, [r, g, b]] of paletteRGB.entries()) { chunks.push(`#${idx};2;${Math.round(r / 255 * 100)};${Math.round(g / 255 * 100)};${Math.round(b / 255 * 100)}`); } // color --> slot // if color does not match a palette color a suitable slot will be calculated from ED later on const colorMap = new Map(paletteWithZero.map((el, idx) => [el, idx])); // process in bands of 6 pixels const bands = []; const data32 = new Uint32Array(data.buffer); for (let b = 0; b < height; b += 6) { bands.push(processBand(data32, b * width, height - b >= 6 ? 6 : height - b, width, colorMap, paletteRGB)); } chunks.push(bands.join('-\n')); return chunks.join(''); } exports.sixelEncode = sixelEncode; /** * Create SIXEL data for a 6 pixel band. * Same as `processBand`, but for correctly indexed colors. */ function processBandIndexed(indices, start, bandHeight, width, last, code, accu, slots) { // reset buffers last.fill(-1); code.fill(0); accu.fill(1); slots.fill(-1); // array to hold band local color idx // only those are processed and written to output // whenever a new color enters here we have to extend the accu/code handling below const usedColorIdx = []; // storage for SIXELs per color in band const targets = []; for (let i = 0; i < width; ++i) { const p = start + i; let rowOffset = 0; code.fill(0, 0, usedColorIdx.length); for (let row = 0; row < bandHeight; ++row) { const idx = indices[p + rowOffset] + 1; // FIXME: handle alpha = 0 case if (slots[idx] === -1) { targets.push([]); // if not at start catch up by writing 0s up to i for new color // (happens during shift below) if (i) { last[usedColorIdx.length] = 0; accu[usedColorIdx.length] = i; } slots[idx] = usedColorIdx.length; usedColorIdx.push(idx); } // update codes for a row of 6 pixels code[slots[idx]] |= 1 << row; rowOffset += width; } // code/last/accu shift, updates SIXELs per color in band for (let j = 0; j < usedColorIdx.length; ++j) { if (code[j] === last[j]) { accu[j]++; } else { if (~last[j]) { targets[j].push(codeToSixel(last[j], accu[j])); } last[j] = code[j]; accu[j] = 1; } } } // handle remaining SIXELs to EOL for (let j = 0; j < usedColorIdx.length; ++j) { if (last[j]) { targets[j].push(codeToSixel(last[j], accu[j])); } } // write sixel chunk for every color in band const result = []; for (let j = 0; j < usedColorIdx.length; ++j) { if (!usedColorIdx[j]) continue; // skip background result.push('#' + (usedColorIdx[j] - 1) + targets[j].join('') + '$'); } return result.join(''); } /** * sixelEncodeIndexed - encode indexed image data to SIXEL string. * Same as `sixelEncode`, but for correctly indexed colors. */ function sixelEncodeIndexed(indices, width, height, palette, rasterAttributes = true) { // some sanity checks if (!indices.length || !width || !height) { return ''; } if (width * height !== indices.length) { throw new Error('wrong geometry of data'); } if (!palette || !palette.length) { throw new Error('palette must not be empty'); } // cleanup/prepare palettes // paletteWithZero: holds background color in slot 0 // paletteRGB: list of [R, G, B] for ED calc const paletteWithZero = [0]; const paletteRGB = []; for (let i = 0; i < palette.length; ++i) { let color = palette[i]; if (typeof color === 'number') { if (!(0, Colors_1.alpha)(color)) continue; color = (0, Colors_1.toRGBA8888)(...(0, Colors_1.fromRGBA8888)(color)); } else { color = (0, Colors_1.toRGBA8888)(...color); } if (!~paletteWithZero.indexOf(color)) { paletteWithZero.push(color); paletteRGB.push((0, Colors_1.fromRGBA8888)(color).slice(0, -1)); } } // SIXEL data storage const chunks = []; // write raster attributes (includes image dimensions) - " Pan ; Pad ; Ph ; Pv // note: Pan/Pad are set to dummies (not eval'd by any terminal) if (rasterAttributes) { chunks.push(`"1;1;${width};${height}`); } // create palette and write color entries for (let [idx, [r, g, b]] of paletteRGB.entries()) { chunks.push(`#${idx};2;${Math.round(r / 255 * 100)};${Math.round(g / 255 * 100)};${Math.round(b / 255 * 100)}`); } // temp buffers to hold various color data on band level // last: last seen SIXEL code per color // code: current SIXEL code per color // accu: count rows with equal SIXEL codes per color // slots: palette color --> idx in usedColorIdx const last = new Int8Array(paletteRGB.length + 1); const code = new Uint8Array(paletteRGB.length + 1); const accu = new Uint16Array(paletteRGB.length + 1); const slots = new Int16Array(paletteRGB.length + 1); // process in bands of 6 pixels const bands = []; for (let b = 0; b < height; b += 6) { bands.push(processBandIndexed(indices, b * width, height - b >= 6 ? 6 : height - b, width, last, code, accu, slots)); } chunks.push(bands.join('-\n')); return chunks.join(''); } exports.sixelEncodeIndexed = sixelEncodeIndexed; /** * Convenient function to create a full SIXEL escape sequence for given image data (alpha). * * Quantization is done by the internal quantizer, with dithering done on 4 neighboring pixels * for speed reasons, which works great for real pictures to level out hard color plane borders, * but might show moiré or striping artefacts on color gradients. * Currently the dithering is not configurable, resort to custom quantizer * library in conjunction with `sixelEncode` if you observe dithering issues. * * @param data pixel data * @param width width of the image * @param height height of the image * @param maxColors max colors of the created palette * @param backgroundSelect background select behavior for transparent pixels */ function image2sixel(data, width, height, maxColors = 256, backgroundSelect = 0) { // FIXME: sixelEncodeIndexed does not yet handle transparent pixels // FIXME: dithering in reduce does not yet respect image width/height const { indices, palette } = (0, Quantizer_1.reduce)(data, width, maxColors); const sixelData = sixelEncodeIndexed(indices, width, height, palette); return [introducer(backgroundSelect), sixelData, exports.FINALIZER].join(''); } exports.image2sixel = image2sixel; //# sourceMappingURL=SixelEncoder.js.map