feat; add font handling

pull/1/head
flop 4 weeks ago
parent 75d3f49631
commit c8a2b03a54
  1. 216
      ts/src/font.ts
  2. 65
      ts/src/renderer.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 0x200x7E.
*/
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 };
}

@ -15,6 +15,7 @@ import {
type MonoFormatVScroll type MonoFormatVScroll
} from "./types.js"; } from "./types.js";
import { type MonoDisplayDriverOptions } from "./driver"; import { type MonoDisplayDriverOptions } from "./driver";
import { rasterizeText } from "./font";
/** /**
* Renders a MonoFormatFile onto an HTMLCanvasElement. * Renders a MonoFormatFile onto an HTMLCanvasElement.
* Uses setInterval at 1000/fps ms per tick. All element state (animation * Uses setInterval at 1000/fps ms per tick. All element state (animation
@ -34,6 +35,8 @@ export class MonoDisplayRenderer {
private animState: Map<number, { frame: number; counter: number }> = new Map(); private animState: Map<number, { frame: number; counter: number }> = new Map();
private hScrollPos: Map<number, number> = new Map(); // pixel offset (may be fractional) private hScrollPos: Map<number, number> = new Map(); // pixel offset (may be fractional)
private vScrollPos: Map<number, number> = new Map(); private vScrollPos: Map<number, number> = new Map();
private customFonts: Uint8Array[] = [];
private textCache: Map<string, MonoFormatPixelImage> = new Map();
constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) { constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) {
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@ -51,6 +54,7 @@ export class MonoDisplayRenderer {
this.animState.clear(); this.animState.clear();
this.hScrollPos.clear(); this.hScrollPos.clear();
this.vScrollPos.clear(); this.vScrollPos.clear();
this.textCache.clear();
} }
render(file: MonoFormatFile): void { render(file: MonoFormatFile): void {
@ -60,6 +64,14 @@ export class MonoDisplayRenderer {
this.ctx.canvas.width = dw * s; this.ctx.canvas.width = dw * s;
this.ctx.canvas.height = dh * 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 = () => { const tick = () => {
this.#renderFile(file); this.#renderFile(file);
this.tickCount++; 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 { #renderClippedText(el: MonoFormatClippedText): void {
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex const img = this.#getTextImage(el.text, el.height, el.fontIndex);
const { ctx, opts: { onColor, scale: s } } = this; this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0);
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();
} }
#renderHScrollText(el: MonoFormatHScrollText, id: number): void { #renderHScrollText(el: MonoFormatHScrollText, id: number): void {
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex const img = this.#getTextImage(el.text, el.height, el.fontIndex);
const { ctx, opts: { onColor, scale: s } } = this; let pos = this.hScrollPos.get(id) ?? 0;
const fontSize = Math.max(6, Math.floor(el.height * s * 0.75));
ctx.font = `${fontSize}px monospace`; this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, pos, 0);
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 speed = (el.scrollSpeed + 1) / 16 * s; const speed = (el.scrollSpeed + 1) / 16;
pos += el.flags.invertDirection ? speed : -speed; pos += el.flags.invertDirection ? -speed : speed;
if (el.flags.endless) { if (el.flags.endless) {
if (pos < -textPx) pos = el.width * s; if (pos >= img.width) pos -= img.width;
if (pos > el.width * s) pos = -textPx; if (pos < 0) pos += img.width;
} else {
pos = Math.max(0, Math.min(pos, img.width - el.width));
} }
this.hScrollPos.set(id, pos); this.hScrollPos.set(id, pos);
} }

Loading…
Cancel
Save