From b9fa8ba3ac0377e2736d4970b7d15845cab01c96 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 20:09:34 +0200 Subject: [PATCH] feat: first font implementation --- ts/src/font.ts | 216 -------------------- ts/src/font/fonts/5x7.ts | 0 ts/src/font/fonts/test.ts | 0 ts/src/font/index.ts | 5 + ts/src/font/types.ts | 11 + ts/src/font/u8g2.ts | 411 ++++++++++++++++++++++++++++++++++++++ ts/src/renderer.ts | 50 ++--- 7 files changed, 454 insertions(+), 239 deletions(-) delete mode 100644 ts/src/font.ts create mode 100644 ts/src/font/fonts/5x7.ts create mode 100644 ts/src/font/fonts/test.ts create mode 100644 ts/src/font/index.ts create mode 100644 ts/src/font/types.ts create mode 100644 ts/src/font/u8g2.ts diff --git a/ts/src/font.ts b/ts/src/font.ts deleted file mode 100644 index d55b4b2..0000000 --- a/ts/src/font.ts +++ /dev/null @@ -1,216 +0,0 @@ -// ============================================================================= -// font.ts — built-in 5×7 bitmap font + text rasteriser -// -// Font encoding: column-major, 5 bytes per glyph (one byte per column). -// Each byte: bit 0 = topmost pixel, bit 6 = bottom pixel (7 rows used). -// Covers ASCII 0x20 (space) … 0x7E (~). Unknown codepoints → replacement glyph. -// -// Custom fonts (U8G2 binary format) accepted but glyph lookup not yet -// implemented — falls back to built-in automatically. -// ============================================================================= - -export const GLYPH_W = 5; // pixel columns per glyph -export const GLYPH_H = 7; // pixel rows per glyph -export const GLYPH_ADVANCE = 6; // pixels advanced per character (glyph + 1px gap) - -// --------------------------------------------------------------------------- -// Built-in 5×7 font data — classic Adafruit GFX / GNU unifont subset -// --------------------------------------------------------------------------- - -// prettier-ignore -const FONT_5x7 = new Uint8Array([ - 0x00, 0x00, 0x00, 0x00, 0x00, // 0x20 ' ' - 0x00, 0x00, 0x5F, 0x00, 0x00, // 0x21 '!' - 0x00, 0x07, 0x00, 0x07, 0x00, // 0x22 '"' - 0x14, 0x7F, 0x14, 0x7F, 0x14, // 0x23 '#' - 0x24, 0x2A, 0x7F, 0x2A, 0x12, // 0x24 '$' - 0x23, 0x13, 0x08, 0x64, 0x62, // 0x25 '%' - 0x36, 0x49, 0x55, 0x22, 0x50, // 0x26 '&' - 0x00, 0x05, 0x03, 0x00, 0x00, // 0x27 "'" - 0x00, 0x1C, 0x22, 0x41, 0x00, // 0x28 '(' - 0x00, 0x41, 0x22, 0x1C, 0x00, // 0x29 ')' - 0x14, 0x08, 0x3E, 0x08, 0x14, // 0x2A '*' - 0x08, 0x08, 0x3E, 0x08, 0x08, // 0x2B '+' - 0x00, 0x50, 0x30, 0x00, 0x00, // 0x2C ',' - 0x08, 0x08, 0x08, 0x08, 0x08, // 0x2D '-' - 0x00, 0x60, 0x60, 0x00, 0x00, // 0x2E '.' - 0x20, 0x10, 0x08, 0x04, 0x02, // 0x2F '/' - 0x3E, 0x51, 0x49, 0x45, 0x3E, // 0x30 '0' - 0x00, 0x42, 0x7F, 0x40, 0x00, // 0x31 '1' - 0x42, 0x61, 0x51, 0x49, 0x46, // 0x32 '2' - 0x21, 0x41, 0x45, 0x4B, 0x31, // 0x33 '3' - 0x18, 0x14, 0x12, 0x7F, 0x10, // 0x34 '4' - 0x27, 0x45, 0x45, 0x45, 0x39, // 0x35 '5' - 0x3C, 0x4A, 0x49, 0x49, 0x30, // 0x36 '6' - 0x01, 0x71, 0x09, 0x05, 0x03, // 0x37 '7' - 0x36, 0x49, 0x49, 0x49, 0x36, // 0x38 '8' - 0x06, 0x49, 0x49, 0x29, 0x1E, // 0x39 '9' - 0x00, 0x36, 0x36, 0x00, 0x00, // 0x3A ':' - 0x00, 0x56, 0x36, 0x00, 0x00, // 0x3B ';' - 0x08, 0x14, 0x22, 0x41, 0x00, // 0x3C '<' - 0x14, 0x14, 0x14, 0x14, 0x14, // 0x3D '=' - 0x00, 0x41, 0x22, 0x14, 0x08, // 0x3E '>' - 0x02, 0x01, 0x51, 0x09, 0x06, // 0x3F '?' - 0x32, 0x49, 0x79, 0x41, 0x3E, // 0x40 '@' - 0x7E, 0x11, 0x11, 0x11, 0x7E, // 0x41 'A' - 0x7F, 0x49, 0x49, 0x49, 0x36, // 0x42 'B' - 0x3E, 0x41, 0x41, 0x41, 0x22, // 0x43 'C' - 0x7F, 0x41, 0x41, 0x22, 0x1C, // 0x44 'D' - 0x7F, 0x49, 0x49, 0x49, 0x41, // 0x45 'E' - 0x7F, 0x09, 0x09, 0x09, 0x01, // 0x46 'F' - 0x3E, 0x41, 0x49, 0x49, 0x7A, // 0x47 'G' - 0x7F, 0x08, 0x08, 0x08, 0x7F, // 0x48 'H' - 0x00, 0x41, 0x7F, 0x41, 0x00, // 0x49 'I' - 0x20, 0x40, 0x41, 0x3F, 0x01, // 0x4A 'J' - 0x7F, 0x08, 0x14, 0x22, 0x41, // 0x4B 'K' - 0x7F, 0x40, 0x40, 0x40, 0x40, // 0x4C 'L' - 0x7F, 0x02, 0x0C, 0x02, 0x7F, // 0x4D 'M' - 0x7F, 0x04, 0x08, 0x10, 0x7F, // 0x4E 'N' - 0x3E, 0x41, 0x41, 0x41, 0x3E, // 0x4F 'O' - 0x7F, 0x09, 0x09, 0x09, 0x06, // 0x50 'P' - 0x3E, 0x41, 0x51, 0x21, 0x5E, // 0x51 'Q' - 0x7F, 0x09, 0x19, 0x29, 0x46, // 0x52 'R' - 0x46, 0x49, 0x49, 0x49, 0x31, // 0x53 'S' - 0x01, 0x01, 0x7F, 0x01, 0x01, // 0x54 'T' - 0x3F, 0x40, 0x40, 0x40, 0x3F, // 0x55 'U' - 0x1F, 0x20, 0x40, 0x20, 0x1F, // 0x56 'V' - 0x3F, 0x40, 0x38, 0x40, 0x3F, // 0x57 'W' - 0x63, 0x14, 0x08, 0x14, 0x63, // 0x58 'X' - 0x07, 0x08, 0x70, 0x08, 0x07, // 0x59 'Y' - 0x61, 0x51, 0x49, 0x45, 0x43, // 0x5A 'Z' - 0x00, 0x7F, 0x41, 0x41, 0x00, // 0x5B '[' - 0x02, 0x04, 0x08, 0x10, 0x20, // 0x5C '\' - 0x00, 0x41, 0x41, 0x7F, 0x00, // 0x5D ']' - 0x04, 0x02, 0x01, 0x02, 0x04, // 0x5E '^' - 0x40, 0x40, 0x40, 0x40, 0x40, // 0x5F '_' - 0x00, 0x01, 0x02, 0x04, 0x00, // 0x60 '`' - 0x20, 0x54, 0x54, 0x54, 0x78, // 0x61 'a' - 0x7F, 0x48, 0x44, 0x44, 0x38, // 0x62 'b' - 0x38, 0x44, 0x44, 0x44, 0x20, // 0x63 'c' - 0x38, 0x44, 0x44, 0x48, 0x7F, // 0x64 'd' - 0x38, 0x54, 0x54, 0x54, 0x18, // 0x65 'e' - 0x08, 0x7E, 0x09, 0x01, 0x02, // 0x66 'f' - 0x0C, 0x52, 0x52, 0x52, 0x3E, // 0x67 'g' - 0x7F, 0x08, 0x04, 0x04, 0x78, // 0x68 'h' - 0x00, 0x44, 0x7D, 0x40, 0x00, // 0x69 'i' - 0x20, 0x40, 0x44, 0x3D, 0x00, // 0x6A 'j' - 0x7F, 0x10, 0x28, 0x44, 0x00, // 0x6B 'k' - 0x00, 0x41, 0x7F, 0x40, 0x00, // 0x6C 'l' - 0x7C, 0x04, 0x18, 0x04, 0x78, // 0x6D 'm' - 0x7C, 0x08, 0x04, 0x04, 0x78, // 0x6E 'n' - 0x38, 0x44, 0x44, 0x44, 0x38, // 0x6F 'o' - 0x7C, 0x14, 0x14, 0x14, 0x08, // 0x70 'p' - 0x08, 0x14, 0x14, 0x18, 0x7C, // 0x71 'q' - 0x7C, 0x08, 0x04, 0x04, 0x08, // 0x72 'r' - 0x48, 0x54, 0x54, 0x54, 0x20, // 0x73 's' - 0x04, 0x3F, 0x44, 0x40, 0x20, // 0x74 't' - 0x3C, 0x40, 0x40, 0x20, 0x7C, // 0x75 'u' - 0x1C, 0x20, 0x40, 0x20, 0x1C, // 0x76 'v' - 0x3C, 0x40, 0x30, 0x40, 0x3C, // 0x77 'w' - 0x44, 0x28, 0x10, 0x28, 0x44, // 0x78 'x' - 0x0C, 0x50, 0x50, 0x50, 0x3C, // 0x79 'y' - 0x44, 0x64, 0x54, 0x4C, 0x44, // 0x7A 'z' - 0x00, 0x08, 0x36, 0x41, 0x00, // 0x7B '{' - 0x00, 0x00, 0x7F, 0x00, 0x00, // 0x7C '|' - 0x00, 0x41, 0x36, 0x08, 0x00, // 0x7D '}' - 0x10, 0x08, 0x08, 0x10, 0x08, // 0x7E '~' -]); - -// Replacement glyph for codepoints outside built-in coverage: filled 5×7 box -// prettier-ignore -const REPLACEMENT_GLYPH = new Uint8Array([0x7F, 0x41, 0x41, 0x41, 0x7F]); - -/** - * Return the 5 column-bytes for a codepoint from the built-in font. - * Returns REPLACEMENT_GLYPH for anything outside ASCII 0x20–0x7E. - */ -export function builtinGlyph(cp: number): Uint8Array { - if (cp >= 0x20 && cp <= 0x7E) { - const off = (cp - 0x20) * GLYPH_W; - return FONT_5x7.subarray(off, off + GLYPH_W); - } - return REPLACEMENT_GLYPH; -} - -// --------------------------------------------------------------------------- -// Custom font (U8G2 binary format) -// --------------------------------------------------------------------------- - -/** - * Look up a glyph in a U8G2 font blob. - * Returns null if the codepoint is not found or the format is unsupported, - * in which case the caller should fall back to builtinGlyph(). - * - * U8G2 font parsing is not yet fully implemented — this is a hook for future - * integration with the u8g2-fonts Rust crate output. - */ -export function u8g2Glyph( - _cp: number, - _fontData: Uint8Array, -): { cols: Uint8Array; w: number; h: number; xOff: number; yOff: number } | null { - // TODO: implement U8G2 binary glyph lookup - return null; -} - -// --------------------------------------------------------------------------- -// Text → pixel-image rasteriser -// --------------------------------------------------------------------------- - -/** - * Rasterise `text` into a 1bpp pixel image. - * Iterates over actual Unicode codepoints (handles surrogate pairs / emoji). - * - * - `cellHeight`: height of the destination cell in pixels; glyphs are - * centred vertically within it. - * - `customFont`: optional raw U8G2 font data; if provided and the glyph is - * found, it is used instead of the built-in font. - * - * Returns a pixel image sized (textWidth × cellHeight) where each byte is 0 - * (off) or 1 (on). - */ -export function rasterizeText( - text: string, - cellHeight: number, - customFont?: Uint8Array, -): { pixels: Uint8Array; width: number; height: number } { - // Collect glyph column data for each codepoint - const glyphs: Array<{ cols: Uint8Array; w: number; h: number; yOff: number }> = []; - - for (const char of text) { // ← real codepoint iteration - const cp = char.codePointAt(0) ?? 0x3F; // '?' fallback - - // Try custom font first, fall back to built-in - const custom = customFont ? u8g2Glyph(cp, customFont) : null; - if (custom) { - glyphs.push({ cols: custom.cols, w: custom.w, h: custom.h, yOff: custom.yOff }); - } else { - glyphs.push({ cols: builtinGlyph(cp), w: GLYPH_W, h: GLYPH_H, yOff: 0 }); - } - } - - // Total pixel width = sum of advances (GLYPH_ADVANCE per glyph, no trailing gap) - const totalW = Math.max(1, glyphs.length * GLYPH_ADVANCE - 1); // remove last gap - const pixels = new Uint8Array(totalW * cellHeight); - - // Vertical centre of glyph within cell - const yBase = Math.max(0, Math.floor((cellHeight - GLYPH_H) / 2)); - - let x = 0; - for (const g of glyphs) { - const top = yBase + g.yOff; - for (let col = 0; col < g.w; col++) { - const colByte = g.cols[col] ?? 0; - for (let row = 0; row < g.h; row++) { - if ((colByte >> row) & 1) { - const py = top + row; - if (py >= 0 && py < cellHeight) { - pixels[py * totalW + x + col] = 1; - } - } - } - } - x += GLYPH_ADVANCE; - } - - return { pixels, width: totalW, height: cellHeight }; -} diff --git a/ts/src/font/fonts/5x7.ts b/ts/src/font/fonts/5x7.ts new file mode 100644 index 0000000..e69de29 diff --git a/ts/src/font/fonts/test.ts b/ts/src/font/fonts/test.ts new file mode 100644 index 0000000..e69de29 diff --git a/ts/src/font/index.ts b/ts/src/font/index.ts new file mode 100644 index 0000000..c1f0884 --- /dev/null +++ b/ts/src/font/index.ts @@ -0,0 +1,5 @@ + + +export * from "./types"; +export * as _5x7 from "./5x7"; +export * from "./u8g2"; \ No newline at end of file diff --git a/ts/src/font/types.ts b/ts/src/font/types.ts new file mode 100644 index 0000000..60f68e1 --- /dev/null +++ b/ts/src/font/types.ts @@ -0,0 +1,11 @@ +export class MonoDisplayFont { + public static FONT_DATA: Uint8Array; +} + +export type RasterizedText = { pixels: Uint8Array; width: number; height: number }; + +export type RasterizeTextFunction = ( + text: string, + cellHeight: number, + font?: MonoDisplayFont, +) => RasterizedText; diff --git a/ts/src/font/u8g2.ts b/ts/src/font/u8g2.ts new file mode 100644 index 0000000..06a4887 --- /dev/null +++ b/ts/src/font/u8g2.ts @@ -0,0 +1,411 @@ +// --------------------------------------------------------------------------- +// U8G2 font binary format constants (header offsets) +// --------------------------------------------------------------------------- +// The U8G2 font blob layout (all offsets are byte positions): +// [0] glyph_cnt – number of glyphs +// [1] bbx_mode – bounding-box mode +// [2] bits_per_0 – RLE bits for "off" run +// [3] bits_per_1 – RLE bits for "on" run +// [4] bits_per_char_width +// [5] bits_per_char_height +// [6] bits_per_char_x +// [7] bits_per_char_y +// [8] bits_per_delta_x +// [9] max_char_width +// [10] max_char_height +// [11] x_offset (signed) +// [12] y_offset (signed) +// [13] ascent_A +// [14] descent_g (negative) +// [15] ascent_para +// [16] descent_para +// [17..18] start_pos_upper (uint16 BE) – offset of uppercase glyph block +// [19..20] start_pos_lower (uint16 BE) – offset of lowercase glyph block +// [21..22] start_pos_unicode (uint16 BE) – offset of Unicode glyph block +// Glyph records follow the three jump-table blocks. +// Each glyph record: +// • A variable-length RLE bitstream (width × height bits) – MSB first +// • Followed by a fixed-width metadata section: +// dwidth (bits_per_delta_x bits) – horizontal advance +// width (bits_per_char_width) +// height (bits_per_char_height) +// x (bits_per_char_x, signed) +// y (bits_per_char_y, signed) +// The very first two bytes of each record encode the encoding (codepoint) +// as a uint16 LE, then a uint16 LE jump offset to the next record. + +import type { MonoDisplayFont } from "./types"; + +// --------------------------------------------------------------------------- +// Built-in 5×7 font (printable ASCII 0x20–0x7E) +// Each entry is 5 column bytes, bit 0 = top row. +// --------------------------------------------------------------------------- +// prettier-ignore +const BUILTIN_FONT_DATA: Record = { + 0x20: [0x00, 0x00, 0x00, 0x00, 0x00], // space + 0x21: [0x00, 0x00, 0x5F, 0x00, 0x00], // ! + 0x22: [0x00, 0x07, 0x00, 0x07, 0x00], // " + 0x23: [0x14, 0x7F, 0x14, 0x7F, 0x14], // # + 0x24: [0x24, 0x2A, 0x7F, 0x2A, 0x12], // $ + 0x25: [0x23, 0x13, 0x08, 0x64, 0x62], // % + 0x26: [0x36, 0x49, 0x55, 0x22, 0x50], // & + 0x27: [0x00, 0x05, 0x03, 0x00, 0x00], // ' + 0x28: [0x00, 0x1C, 0x22, 0x41, 0x00], // ( + 0x29: [0x00, 0x41, 0x22, 0x1C, 0x00], // ) + 0x2A: [0x14, 0x08, 0x3E, 0x08, 0x14], // * + 0x2B: [0x08, 0x08, 0x3E, 0x08, 0x08], // + + 0x2C: [0x00, 0x50, 0x30, 0x00, 0x00], // , + 0x2D: [0x08, 0x08, 0x08, 0x08, 0x08], // - + 0x2E: [0x00, 0x60, 0x60, 0x00, 0x00], // . + 0x2F: [0x20, 0x10, 0x08, 0x04, 0x02], // / + 0x30: [0x3E, 0x51, 0x49, 0x45, 0x3E], // 0 + 0x31: [0x00, 0x42, 0x7F, 0x40, 0x00], // 1 + 0x32: [0x42, 0x61, 0x51, 0x49, 0x46], // 2 + 0x33: [0x21, 0x41, 0x45, 0x4B, 0x31], // 3 + 0x34: [0x18, 0x14, 0x12, 0x7F, 0x10], // 4 + 0x35: [0x27, 0x45, 0x45, 0x45, 0x39], // 5 + 0x36: [0x3C, 0x4A, 0x49, 0x49, 0x30], // 6 + 0x37: [0x01, 0x71, 0x09, 0x05, 0x03], // 7 + 0x38: [0x36, 0x49, 0x49, 0x49, 0x36], // 8 + 0x39: [0x06, 0x49, 0x49, 0x29, 0x1E], // 9 + 0x3A: [0x00, 0x36, 0x36, 0x00, 0x00], // : + 0x3B: [0x00, 0x56, 0x36, 0x00, 0x00], // ; + 0x3C: [0x08, 0x14, 0x22, 0x41, 0x00], // < + 0x3D: [0x14, 0x14, 0x14, 0x14, 0x14], // = + 0x3E: [0x00, 0x41, 0x22, 0x14, 0x08], // > + 0x3F: [0x02, 0x01, 0x51, 0x09, 0x06], // ? + 0x40: [0x32, 0x49, 0x79, 0x41, 0x3E], // @ + 0x41: [0x7E, 0x11, 0x11, 0x11, 0x7E], // A + 0x42: [0x7F, 0x49, 0x49, 0x49, 0x36], // B + 0x43: [0x3E, 0x41, 0x41, 0x41, 0x22], // C + 0x44: [0x7F, 0x41, 0x41, 0x22, 0x1C], // D + 0x45: [0x7F, 0x49, 0x49, 0x49, 0x41], // E + 0x46: [0x7F, 0x09, 0x09, 0x09, 0x01], // F + 0x47: [0x3E, 0x41, 0x49, 0x49, 0x7A], // G + 0x48: [0x7F, 0x08, 0x08, 0x08, 0x7F], // H + 0x49: [0x00, 0x41, 0x7F, 0x41, 0x00], // I + 0x4A: [0x20, 0x40, 0x41, 0x3F, 0x01], // J + 0x4B: [0x7F, 0x08, 0x14, 0x22, 0x41], // K + 0x4C: [0x7F, 0x40, 0x40, 0x40, 0x40], // L + 0x4D: [0x7F, 0x02, 0x0C, 0x02, 0x7F], // M + 0x4E: [0x7F, 0x04, 0x08, 0x10, 0x7F], // N + 0x4F: [0x3E, 0x41, 0x41, 0x41, 0x3E], // O + 0x50: [0x7F, 0x09, 0x09, 0x09, 0x06], // P + 0x51: [0x3E, 0x41, 0x51, 0x21, 0x5E], // Q + 0x52: [0x7F, 0x09, 0x19, 0x29, 0x46], // R + 0x53: [0x46, 0x49, 0x49, 0x49, 0x31], // S + 0x54: [0x01, 0x01, 0x7F, 0x01, 0x01], // T + 0x55: [0x3F, 0x40, 0x40, 0x40, 0x3F], // U + 0x56: [0x1F, 0x20, 0x40, 0x20, 0x1F], // V + 0x57: [0x3F, 0x40, 0x38, 0x40, 0x3F], // W + 0x58: [0x63, 0x14, 0x08, 0x14, 0x63], // X + 0x59: [0x07, 0x08, 0x70, 0x08, 0x07], // Y + 0x5A: [0x61, 0x51, 0x49, 0x45, 0x43], // Z + 0x5B: [0x00, 0x7F, 0x41, 0x41, 0x00], // [ + 0x5C: [0x02, 0x04, 0x08, 0x10, 0x20], // backslash + 0x5D: [0x00, 0x41, 0x41, 0x7F, 0x00], // ] + 0x5E: [0x04, 0x02, 0x01, 0x02, 0x04], // ^ + 0x5F: [0x40, 0x40, 0x40, 0x40, 0x40], // _ + 0x60: [0x00, 0x01, 0x02, 0x04, 0x00], // ` + 0x61: [0x20, 0x54, 0x54, 0x54, 0x78], // a + 0x62: [0x7F, 0x48, 0x44, 0x44, 0x38], // b + 0x63: [0x38, 0x44, 0x44, 0x44, 0x20], // c + 0x64: [0x38, 0x44, 0x44, 0x48, 0x7F], // d + 0x65: [0x38, 0x54, 0x54, 0x54, 0x18], // e + 0x66: [0x08, 0x7E, 0x09, 0x01, 0x02], // f + 0x67: [0x0C, 0x52, 0x52, 0x52, 0x3E], // g + 0x68: [0x7F, 0x08, 0x04, 0x04, 0x78], // h + 0x69: [0x00, 0x44, 0x7D, 0x40, 0x00], // i + 0x6A: [0x20, 0x40, 0x44, 0x3D, 0x00], // j + 0x6B: [0x7F, 0x10, 0x28, 0x44, 0x00], // k + 0x6C: [0x00, 0x41, 0x7F, 0x40, 0x00], // l + 0x6D: [0x7C, 0x04, 0x18, 0x04, 0x78], // m + 0x6E: [0x7C, 0x08, 0x04, 0x04, 0x78], // n + 0x6F: [0x38, 0x44, 0x44, 0x44, 0x38], // o + 0x70: [0x7C, 0x14, 0x14, 0x14, 0x08], // p + 0x71: [0x08, 0x14, 0x14, 0x18, 0x7C], // q + 0x72: [0x7C, 0x08, 0x04, 0x04, 0x08], // r + 0x73: [0x48, 0x54, 0x54, 0x54, 0x20], // s + 0x74: [0x04, 0x3F, 0x44, 0x40, 0x20], // t + 0x75: [0x3C, 0x40, 0x40, 0x20, 0x7C], // u + 0x76: [0x1C, 0x20, 0x40, 0x20, 0x1C], // v + 0x77: [0x3C, 0x40, 0x30, 0x40, 0x3C], // w + 0x78: [0x44, 0x28, 0x10, 0x28, 0x44], // x + 0x79: [0x0C, 0x50, 0x50, 0x50, 0x3C], // y + 0x7A: [0x44, 0x64, 0x54, 0x4C, 0x44], // z + 0x7B: [0x00, 0x08, 0x36, 0x41, 0x00], // { + 0x7C: [0x00, 0x00, 0x7F, 0x00, 0x00], // | + 0x7D: [0x00, 0x41, 0x36, 0x08, 0x00], // } + 0x7E: [0x10, 0x08, 0x08, 0x10, 0x08], // ~ +}; + +// Built-in font metrics (5×7, 1px column gap → advance = 6) +export const GLYPH_W = 5; +export const GLYPH_H = 7; +export const GLYPH_ADVANCE = 6; // width + 1px gap + +/** + * Return column bytes for a codepoint from the built-in 5×7 font. + * Falls back to '?' (0x3F) for unknown codepoints. + */ +export function builtinGlyph(cp: number): Uint8Array { + const cols = BUILTIN_FONT_DATA[cp] ?? BUILTIN_FONT_DATA[0x3F]!; + return new Uint8Array(cols); +} + +// --------------------------------------------------------------------------- +// U8G2 binary font parser +// --------------------------------------------------------------------------- + +/** Bit-level reader over a Uint8Array, MSB-first within each byte. */ +class BitReader { + private pos = 0; // bit position + constructor(private data: Uint8Array, startBit = 0) { + this.pos = startBit; + } + + read(n: number): number { + let v = 0; + for (let i = 0; i < n; i++) { + const byteIdx = (this.pos) >> 3; + const bitIdx = 7 - ((this.pos) & 7); // MSB first + v = (v << 1) | ((this.data[byteIdx]! >> bitIdx) & 1); + this.pos++; + } + return v; + } + + /** Read a signed value stored in `n` bits (two's complement). */ + readSigned(n: number): number { + const v = this.read(n); + const sign = 1 << (n - 1); + return v & sign ? v - (sign << 1) : v; + } + + get bitPos(): number { return this.pos; } +} + +/** Read a big-endian uint16 from a byte array at `offset`. */ +function readU16BE(data: Uint8Array, offset: number): number { + return ((data[offset]! << 8) | data[offset + 1]!) >>> 0; +} + +/** + * Glyph result from a U8G2 font blob. + * `cols` – column bytes (bit 0 = top row), length = w. + */ +export interface U8G2GlyphData { + cols: Uint8Array; + w: number; + h: number; + yOff: number; // positive = shift down +} + +/** + * Parse header fields from a U8G2 font blob and return the key metrics + * needed to decode glyphs. + */ +function parseHeader(font: Uint8Array) { + return { + glyphCnt: font[0]!, + bbxMode: font[1]!, + bitsPerRle0: font[2]!, + bitsPerRle1: font[3]!, + bitsPerW: font[4]!, + bitsPerH: font[5]!, + bitsPerX: font[6]!, + bitsPerY: font[7]!, + bitsPerDx: font[8]!, + maxCharW: font[9]!, + maxCharH: font[10]!, + xOff: font[11]! as number, // signed but treated as-is + yOff: font[12]! as number, + ascentA: font[13]!, + descentG: font[14]!, + startUpper: readU16BE(font, 17), + startLower: readU16BE(font, 19), + startUnicode: readU16BE(font, 21), + }; +} + +/** + * Decode one glyph from the U8G2 font RLE bitstream at `byteOffset`. + * Returns decoded data or null if the stream is malformed. + * + * U8G2 glyph encoding (bitstream, MSB-first): + * [bitsPerW] w glyph bitmap width + * [bitsPerH] h glyph bitmap height + * [bitsPerX] x signed x bearing + * [bitsPerY] y signed y bearing (positive = up from baseline) + * [bitsPerDx] dwidth horizontal advance + * Then w×h bits of RLE-encoded bitmap: + * repeat: + * 1-bit type (0 = off-run, 1 = on-run) + * bitsPerRleN count bits + */ +function decodeGlyphAt( + font: Uint8Array, + byteOffset: number, + hdr: ReturnType, +): U8G2GlyphData | null { + try { + const br = new BitReader(font, byteOffset * 8); + + const w = br.read(hdr.bitsPerW); + const h = br.read(hdr.bitsPerH); + const xBrg = br.readSigned(hdr.bitsPerX); + const yBrg = br.readSigned(hdr.bitsPerY); // positive = up + br.read(hdr.bitsPerDx); // advance – not needed here + + if (w === 0 || h === 0) { + // Blank glyph (e.g. space) + return { cols: new Uint8Array(GLYPH_W), w: GLYPH_W, h: GLYPH_H, yOff: 0 }; + } + + // Decode RLE bitmap into a flat bit array [row-major, top-to-bottom] + const totalBits = w * h; + const bits = new Uint8Array(totalBits); + let filled = 0; + + while (filled < totalBits) { + const type = br.read(1); // 0=off, 1=on + const bitsN = type === 0 ? hdr.bitsPerRle0 : hdr.bitsPerRle1; + const count = br.read(bitsN) + 1; // stored as count-1 + const val = type & 1; + for (let i = 0; i < count && filled < totalBits; i++) { + bits[filled++] = val; + } + } + + // Convert row-major bitmap → column bytes (bit 0 = top row) to match + // the builtin font layout consumed by rasterizeText. + const cols = new Uint8Array(w); + for (let row = 0; row < h; row++) { + for (let col = 0; col < w; col++) { + if (bits[row * w + col]) { + // ts-ignore + cols[col] |= 1 << row; + } + } + } + + // yBrg is measured upward from baseline in U8G2 conventions. + // Convert to a downward pixel offset relative to the top of GLYPH_H. + // ascentA = pixels above baseline for capital 'A' + // yOff = (ascentA - yBrg - h): rows to push down from the top of + // the glyph box so the baseline lands in the right place. + const yOff = Math.max(0, hdr.ascentA - yBrg - h); + + return { cols, w, h, yOff }; + } catch { + return null; + } +} + +/** + * Scan the correct block of a U8G2 font for `cp` and return decoded glyph + * data, or null if not found. + * + * Each glyph record in the jump table starts with: + * [1 byte] encoding (codepoint, 0x00–0xFF in ASCII blocks) + * [1 byte] jump offset to next record (0 = end of block) + * then the bitstream described in decodeGlyphAt. + * + * For the Unicode block the encoding is 2 bytes (uint16 LE) and the jump + * offset is also 2 bytes (uint16 LE). + */ +export function u8g2Glyph(cp: number, font: Uint8Array): U8G2GlyphData | null { + if (font.length < 23) return null; + + const hdr = parseHeader(font); + + let blockStart: number; + let wide: boolean; // true for the Unicode block (2-byte encoding + 2-byte jump) + + if (cp >= 0x20 && cp <= 0x5F) { + blockStart = hdr.startUpper; + wide = false; + } else if (cp >= 0x60 && cp <= 0x9F) { + blockStart = hdr.startLower; + wide = false; + } else { + blockStart = hdr.startUnicode; + wide = true; + } + + if (blockStart === 0 || blockStart >= font.length) return null; + + let pos = blockStart; + + while (pos < font.length) { + let encoding: number; + let jump: number; + + if (wide) { + if (pos + 4 > font.length) break; + encoding = (font[pos]! | (font[pos + 1]! << 8)) >>> 0; // LE uint16 + jump = (font[pos + 2]! | (font[pos + 3]! << 8)) >>> 0; + pos += 4; + } else { + if (pos + 2 > font.length) break; + encoding = font[pos]!; + jump = font[pos + 1]!; + pos += 2; + } + + if (jump === 0) break; // end-of-block sentinel + + if (encoding === cp) { + return decodeGlyphAt(font, pos, hdr); + } + + pos += jump; // skip to next record + } + + return null; // codepoint not in font +} + +// --------------------------------------------------------------------------- +// Text → pixel-image rasteriser (as specified) +// --------------------------------------------------------------------------- +export function rasterizeText( + text: string, + cellHeight: number, + font?: MonoDisplayFont, +): { pixels: Uint8Array; width: number; height: number } { + const glyphs: Array<{ cols: Uint8Array; w: number; h: number; yOff: number }> = []; + + for (const char of text) { + const cp = char.codePointAt(0) ?? 0x3F; + const custom = font ? u8g2Glyph(cp, font.FONT_DATA) : null; + if (custom) { + glyphs.push({ cols: custom.cols, w: custom.w, h: custom.h, yOff: custom.yOff }); + } else { + glyphs.push({ cols: builtinGlyph(cp), w: GLYPH_W, h: GLYPH_H, yOff: 0 }); + } + } + + const totalW = Math.max(1, glyphs.length * GLYPH_ADVANCE - 1); + const pixels = new Uint8Array(totalW * cellHeight); + + const yBase = Math.max(0, Math.floor((cellHeight - GLYPH_H) / 2)); + let x = 0; + + for (const g of glyphs) { + const top = yBase + g.yOff; + for (let col = 0; col < g.w; col++) { + const colByte = g.cols[col] ?? 0; + for (let row = 0; row < g.h; row++) { + if ((colByte >> row) & 1) { + const py = top + row; + if (py >= 0 && py < cellHeight) { + pixels[py * totalW + x + col] = 1; + } + } + } + } + x += GLYPH_ADVANCE; + } + + return { pixels, width: totalW, height: cellHeight }; +} \ No newline at end of file diff --git a/ts/src/renderer.ts b/ts/src/renderer.ts index c2a1e13..ba79346 100644 --- a/ts/src/renderer.ts +++ b/ts/src/renderer.ts @@ -16,7 +16,7 @@ import { type MonoFormatVScroll } from "./types.js"; import { type MonoDisplayDriverOptions } from "./driver"; -import { rasterizeText } from "./font"; +import { rasterizeText, MonoDisplayFont } from "./font"; /** * Renders a MonoFormatFile onto an HTMLCanvasElement. * Uses setInterval at 1000/fps ms per tick. All element state (animation @@ -30,20 +30,24 @@ export class MonoDisplayRenderer { private ctx: CanvasRenderingContext2D; private opts: Required; - private tickTimer: number | null = null; - private tickCount: number = 0; + private tickTimer: number | null = null; + private tickCount: number = 0; // Per-element state: keyed by stable element index - private animState: Map = new Map(); - private hScrollPos: Map = new Map(); // pixel offset (may be fractional) - private vScrollPos: Map = new Map(); - private customFonts: Uint8Array[] = []; - private textCache: Map = new Map(); + private animState: Map = new Map(); + private hScrollPos: Map = new Map(); // pixel offset (may be fractional) + private vScrollPos: Map = new Map(); + private customFonts: MonoDisplayFont[] = []; + private textCache: Map = new Map(); + private builtinFonts: MonoDisplayFont[]; constructor(canvas: HTMLCanvasElement, opts: Required) { const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Cannot get 2D canvas context"); - this.ctx = ctx; + this.ctx = ctx; this.opts = opts; + this.builtinFonts = [ + + ]; } stop(): void { @@ -51,7 +55,7 @@ export class MonoDisplayRenderer { clearInterval(this.tickTimer); this.tickTimer = null; } - this.tickCount = 0; + this.tickCount = 0; this.animState.clear(); this.hScrollPos.clear(); this.vScrollPos.clear(); @@ -62,7 +66,7 @@ export class MonoDisplayRenderer { this.stop(); const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; - this.ctx.canvas.width = dw * s; + this.ctx.canvas.width = dw * s; this.ctx.canvas.height = dh * s; // Extract custom fonts from file sections (0x8000-indexed) @@ -98,7 +102,7 @@ export class MonoDisplayRenderer { } if (section.sectionType !== SectionType.ElementsAlways && - section.sectionType !== SectionType.ElementsTimespan) { + section.sectionType !== SectionType.ElementsTimespan) { continue; } @@ -118,14 +122,14 @@ export class MonoDisplayRenderer { #renderElement(el: MonoFormatElement, id: number): void { switch (el.type) { - case ElementType.Image2D: this.#blitImage(el.image, el.xOffset, el.yOffset); break; - case ElementType.Animation: this.#renderAnimation(el, id); break; + case ElementType.Image2D: this.#blitImage(el.image, el.xOffset, el.yOffset); break; + case ElementType.Animation: this.#renderAnimation(el, id); break; case ElementType.HorizontalScroll: this.#renderHScroll(el, id); break; - case ElementType.VerticalScroll: this.#renderVScroll(el, id); break; - case ElementType.Line: this.#renderLine(el); break; - case ElementType.ClippedText: this.#renderClippedText(el); break; - case ElementType.HScrollText: this.#renderHScrollText(el, id); break; - case ElementType.CurrentTime: this.#renderCurrentTime(el); break; + case ElementType.VerticalScroll: this.#renderVScroll(el, id); break; + case ElementType.Line: this.#renderLine(el); break; + case ElementType.ClippedText: this.#renderClippedText(el); break; + case ElementType.HScrollText: this.#renderHScrollText(el, id); break; + case ElementType.CurrentTime: this.#renderCurrentTime(el); break; } } @@ -211,11 +215,11 @@ export class MonoDisplayRenderer { const { ctx, opts: { onColor, offColor, scale: s } } = this; ctx.fillStyle = el.invertPixels ? offColor : onColor; let [x0, y0, x1, y1] = [el.xOrigin, el.yOrigin, el.xTarget, el.yTarget]; - const dx = Math.abs(x1-x0), sx = x0 < x1 ? 1 : -1; - const dy = -Math.abs(y1-y0), sy = y0 < y1 ? 1 : -1; + const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; + const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; let err = dx + dy; while (true) { - ctx.fillRect(x0*s, y0*s, s, s); + ctx.fillRect(x0 * s, y0 * s, s, s); if (x0 === x1 && y0 === y1) break; const e2 = 2 * err; if (e2 >= dy) { err += dy; x0 += sx; } @@ -227,7 +231,7 @@ export class MonoDisplayRenderer { const key = `${text}|${fontIndex}|${height}`; const cached = this.textCache.get(key); if (cached) return cached; - const customFont = fontIndex >= 0x8000 ? this.customFonts[fontIndex - 0x8000] : undefined; + const customFont = fontIndex >= 0x8000 ? this.customFonts[fontIndex - 0x8000] : this.builtinFonts[fontIndex]; const img = rasterizeText(text, height, customFont); this.textCache.set(key, img); return img;