/** * Turns the rendered SVG into what the panel actually consumes: a 1-bit * 800x480 image. * * The firmware on this kit references `.bmp`, but the documentation is not * explicit, so both encoders live here and `DEVICE_IMAGE_FORMAT` chooses. The * bit-packing is shared, so whichever format wins shows identical pixels. * * There is deliberately no dithering. The source is flat black on flat white; * dithering would only fuzz the text edges and cost contrast on a panel that * has none to spare. */ import { deflateSync } from 'node:zlib'; import { Resvg } from '@resvg/resvg-js'; import { SCREEN_HEIGHT, SCREEN_WIDTH } from './layout'; export type Bitmap = { width: number; height: number; /** One bit per pixel, MSB first, row-major. A set bit is white. */ bits: Uint8Array; }; /** Mid-grey. Anti-aliased glyph edges fall either side of it. */ export const DEFAULT_THRESHOLD = 127; export type EncodeOptions = { threshold?: number; /** * Flip black and white. The knob to reach for if the panel ever comes out * as a negative; the pixels are otherwise correct. */ invert?: boolean; }; /** Rasterises the SVG at its natural size and returns RGBA pixels. */ export function rasterise(svg: string): { width: number; height: number; rgba: Buffer } { const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: SCREEN_WIDTH } }); const rendered = resvg.render(); return { width: rendered.width, height: rendered.height, rgba: rendered.pixels }; } /** Thresholds RGBA pixels into one bit each, no dithering. */ export function toBitmap( rgba: Buffer, width: number, height: number, { threshold = DEFAULT_THRESHOLD, invert = false }: EncodeOptions = {}, ): Bitmap { const bytesPerRow = Math.ceil(width / 8); const bits = new Uint8Array(bytesPerRow * height); for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const offset = (y * width + x) * 4; const red = rgba[offset] ?? 0; const green = rgba[offset + 1] ?? 0; const blue = rgba[offset + 2] ?? 0; const alpha = rgba[offset + 3] ?? 255; // Composite over white first: the panel is paper, so anything // transparent is paper too, not ink. const opacity = alpha / 255; const over = (channel: number): number => channel * opacity + 255 * (1 - opacity); const luminance = 0.299 * over(red) + 0.587 * over(green) + 0.114 * over(blue); const isWhite = invert ? luminance <= threshold : luminance > threshold; if (isWhite) { const index = y * bytesPerRow + (x >> 3); bits[index] = (bits[index] ?? 0) | (0b1000_0000 >> (x & 7)); } } } return { width, height, bits }; } /** * A 1-bit BMP: 14-byte file header, 40-byte info header, a two-colour palette, * then bottom-up rows padded to four bytes. Uncompressed on purpose — the * firmware reads it straight through. */ export function encodeBmp(bitmap: Bitmap): Buffer { const { width, height, bits } = bitmap; const sourceRowBytes = Math.ceil(width / 8); const rowBytes = Math.ceil(width / 32) * 4; const pixelBytes = rowBytes * height; const offset = 14 + 40 + 8; const buffer = Buffer.alloc(offset + pixelBytes); buffer.write('BM', 0, 'ascii'); buffer.writeUInt32LE(buffer.length, 2); buffer.writeUInt32LE(0, 6); buffer.writeUInt32LE(offset, 10); buffer.writeUInt32LE(40, 14); buffer.writeInt32LE(width, 18); buffer.writeInt32LE(height, 22); buffer.writeUInt16LE(1, 26); buffer.writeUInt16LE(1, 28); buffer.writeUInt32LE(0, 30); buffer.writeUInt32LE(pixelBytes, 34); buffer.writeInt32LE(2835, 38); buffer.writeInt32LE(2835, 42); buffer.writeUInt32LE(2, 46); buffer.writeUInt32LE(2, 50); // Palette, BGRA. Index 0 is black, index 1 is white. buffer.writeUInt32LE(0x00000000, 54); buffer.writeUInt32LE(0x00ffffff, 58); // BMP rows run bottom to top. for (let y = 0; y < height; y += 1) { const source = (height - 1 - y) * sourceRowBytes; const target = offset + y * rowBytes; for (let byte = 0; byte < sourceRowBytes; byte += 1) { buffer[target + byte] = bits[source + byte] ?? 0; } } return buffer; } const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** A 1-bit greyscale PNG, for firmwares that prefer it to BMP. */ export function encodePng(bitmap: Bitmap): Buffer { const { width, height, bits } = bitmap; const rowBytes = Math.ceil(width / 8); const header = Buffer.alloc(13); header.writeUInt32BE(width, 0); header.writeUInt32BE(height, 4); header.writeUInt8(1, 8); // bit depth header.writeUInt8(0, 9); // greyscale header.writeUInt8(0, 10); header.writeUInt8(0, 11); header.writeUInt8(0, 12); // Every scanline carries a leading filter byte; "none" keeps it readable. const raw = Buffer.alloc((rowBytes + 1) * height); for (let y = 0; y < height; y += 1) { const target = y * (rowBytes + 1); raw[target] = 0; for (let byte = 0; byte < rowBytes; byte += 1) { raw[target + 1 + byte] = bits[y * rowBytes + byte] ?? 0; } } return Buffer.concat([ PNG_SIGNATURE, chunk('IHDR', header), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)), ]); } function chunk(type: string, data: Buffer): Buffer { const length = Buffer.alloc(4); length.writeUInt32BE(data.length, 0); const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0); return Buffer.concat([length, body, crc]); } const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let index = 0; index < 256; index += 1) { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } table[index] = value >>> 0; } return table; })(); function crc32(data: Buffer): number { let crc = 0xffffffff; for (const byte of data) { crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } /** The whole pipeline: SVG in, panel-ready bytes out. */ export function encodeScreen( svg: string, format: 'bmp' | 'png', options: EncodeOptions = {}, ): Buffer { const { width, height, rgba } = rasterise(svg); const bitmap = toBitmap(rgba, width, height, options); if (bitmap.width !== SCREEN_WIDTH || bitmap.height !== SCREEN_HEIGHT) { throw new Error( `Rendered ${bitmap.width}x${bitmap.height}, expected ${SCREEN_WIDTH}x${SCREEN_HEIGHT}`, ); } return format === 'png' ? encodePng(bitmap) : encodeBmp(bitmap); }