From c8a2b03a547c77afc0d531d3fd3bb6193222ee09 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH] feat; add font handling --- ts/src/font.ts | 216 +++++++++++++++++++++++++++++++++++++++++++++ ts/src/renderer.ts | 65 +++++++------- 2 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 ts/src/font.ts diff --git a/ts/src/font.ts b/ts/src/font.ts new file mode 100644 index 0000000..d55b4b2 --- /dev/null +++ b/ts/src/font.ts @@ -0,0 +1,216 @@ +// ============================================================================= +// 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/renderer.ts b/ts/src/renderer.ts index 5e6d7b5..b1c5a82 100644 --- a/ts/src/renderer.ts +++ b/ts/src/renderer.ts @@ -15,6 +15,7 @@ import { type MonoFormatVScroll } from "./types.js"; import { type MonoDisplayDriverOptions } from "./driver"; +import { rasterizeText } from "./font"; /** * Renders a MonoFormatFile onto an HTMLCanvasElement. * Uses setInterval at 1000/fps ms per tick. All element state (animation @@ -34,6 +35,8 @@ export class MonoDisplayRenderer { 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(); constructor(canvas: HTMLCanvasElement, opts: Required) { const ctx = canvas.getContext("2d"); @@ -51,6 +54,7 @@ export class MonoDisplayRenderer { this.animState.clear(); this.hScrollPos.clear(); this.vScrollPos.clear(); + this.textCache.clear(); } render(file: MonoFormatFile): void { @@ -60,6 +64,14 @@ export class MonoDisplayRenderer { this.ctx.canvas.width = dw * s; this.ctx.canvas.height = dh * s; + // Extract custom fonts from file sections (0x8000-indexed) + this.customFonts = []; + for (const section of file.sections) { + if (section.sectionType === SectionType.CustomFont) { + this.customFonts.push(section.fontData); + } + } + const tick = () => { this.#renderFile(file); this.tickCount++; @@ -209,43 +221,34 @@ export class MonoDisplayRenderer { } } + #getTextImage(text: string, height: number, fontIndex: number): MonoFormatPixelImage { + 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 img = rasterizeText(text, height, customFont); + this.textCache.set(key, img); + return img; + } + #renderClippedText(el: MonoFormatClippedText): void { - // PLAN: replace with U8G2 bitmap font renderer using el.fontIndex - const { ctx, opts: { onColor, scale: s } } = this; - ctx.save(); - ctx.beginPath(); - ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s); - ctx.clip(); - ctx.fillStyle = onColor; - const fontSize = Math.max(6, Math.floor(el.height * s * 0.75)); - ctx.font = `${fontSize}px monospace`; - ctx.textBaseline = "top"; - ctx.fillText(el.text, el.xOffset * s, el.yOffset * s); - ctx.restore(); + const img = this.#getTextImage(el.text, el.height, el.fontIndex); + this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); } #renderHScrollText(el: MonoFormatHScrollText, id: number): void { - // PLAN: replace with U8G2 bitmap font renderer using el.fontIndex - const { ctx, opts: { onColor, scale: s } } = this; - const fontSize = Math.max(6, Math.floor(el.height * s * 0.75)); - ctx.font = `${fontSize}px monospace`; - const textPx = ctx.measureText(el.text).width; - let pos = this.hScrollPos.get(id) ?? el.width * s; - - ctx.save(); - ctx.beginPath(); - ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s); - ctx.clip(); - ctx.fillStyle = onColor; - ctx.textBaseline = "top"; - ctx.fillText(el.text, el.xOffset * s + pos, el.yOffset * s); - ctx.restore(); + const img = this.#getTextImage(el.text, el.height, el.fontIndex); + let pos = this.hScrollPos.get(id) ?? 0; + + this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, pos, 0); - const speed = (el.scrollSpeed + 1) / 16 * s; - pos += el.flags.invertDirection ? speed : -speed; + const speed = (el.scrollSpeed + 1) / 16; + pos += el.flags.invertDirection ? -speed : speed; if (el.flags.endless) { - if (pos < -textPx) pos = el.width * s; - if (pos > el.width * s) pos = -textPx; + if (pos >= img.width) pos -= img.width; + if (pos < 0) pos += img.width; + } else { + pos = Math.max(0, Math.min(pos, img.width - el.width)); } this.hScrollPos.set(id, pos); }