import type { Canvas } from "./canvas.ts"; import { parseColor } from "./line.ts"; const GLYPH_WIDTH = 5; const GLYPH_HEIGHT = 7; /** * A tiny 5x7 pixel font, hand-drawn as ASCII art (# = lit, . = off) so each * glyph's source reads as the letter it draws. Covers A-Z, a-z, digits, * and the punctuation a tile-attribution string, a route title, or a * distance/elevation stat is likely to need. */ const GLYPHS_ASCII: Record = { " ": [".....", ".....", ".....", ".....", ".....", ".....", "....."], ",": [".....", ".....", ".....", ".....", "..#..", "..#..", ".#..."], ".": [".....", ".....", ".....", ".....", ".....", "..#..", "....."], "©": [".###.", "#...#", "#.#.#", "#.#..", "#.#.#", "#...#", ".###."], "+": [".....", "..#..", "..#..", "#####", "..#..", "..#..", "....."], "-": [".....", ".....", ".....", "#####", ".....", ".....", "....."], "'": ["..#..", "..#..", ".#...", ".....", ".....", ".....", "....."], "(": ["...#.", "..#..", ".#...", ".#...", ".#...", "..#..", "...#."], ")": [".#...", "..#..", "...#.", "...#.", "...#.", "..#..", ".#..."], "!": ["..#..", "..#..", "..#..", "..#..", "..#..", ".....", "..#.."], "?": [".###.", "#...#", "....#", "..##.", "..#..", ".....", "..#.."], ":": [".....", ".....", "..#..", ".....", "..#..", ".....", "....."], "0": [".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###."], "1": ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."], "2": [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"], "3": [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."], "4": ["...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."], "5": ["#####", "#....", "#....", "####.", "....#", "#...#", ".###."], "6": ["..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."], "7": ["#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."], "8": [".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."], "9": [".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."], A: [".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"], B: ["####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."], C: [".####", "#....", "#....", "#....", "#....", "#....", ".####"], D: ["###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###.."], E: ["#####", "#....", "#....", "####.", "#....", "#....", "#####"], F: ["#####", "#....", "#....", "####.", "#....", "#....", "#...."], G: [".####", "#....", "#....", "#.###", "#...#", "#...#", ".####"], H: ["#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"], I: ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"], J: ["..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.."], K: ["#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"], L: ["#....", "#....", "#....", "#....", "#....", "#....", "#####"], M: ["#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"], N: ["#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"], O: [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."], P: ["####.", "#...#", "#...#", "####.", "#....", "#....", "#...."], Q: [".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"], R: ["####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"], S: [".####", "#....", "#....", ".###.", "....#", "....#", "####."], T: ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."], U: ["#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."], V: ["#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."], W: ["#...#", "#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"], X: ["#...#", ".#.#.", "..#..", "..#..", "..#..", ".#.#.", "#...#"], Y: ["#...#", ".#.#.", "..#..", "..#..", "..#..", "..#..", "..#.."], Z: ["#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"], a: [".....", ".....", ".###.", "....#", ".####", "#...#", ".####"], b: ["#....", "#....", "####.", "#...#", "#...#", "#...#", "####."], c: [".....", ".....", ".####", "#....", "#....", "#....", ".####"], d: ["....#", "....#", ".####", "#...#", "#...#", "#...#", ".####"], e: [".....", ".....", ".###.", "#...#", "#####", "#....", ".####"], f: ["..##.", ".#..#", ".#...", "###..", ".#...", ".#...", ".#..."], g: [".....", ".####", "#...#", "#...#", ".####", "....#", ".###."], h: ["#....", "#....", "####.", "#...#", "#...#", "#...#", "#...#"], i: ["..#..", ".....", ".##..", "..#..", "..#..", "..#..", ".###."], j: ["...#.", ".....", "..##.", "...#.", "...#.", "#..#.", ".##.."], k: ["#....", "#....", "#..#.", "#.#..", "##...", "#.#..", "#..#."], l: [".##..", "..#..", "..#..", "..#..", "..#..", "..#..", ".###."], m: [".....", ".....", "##.#.", "#.#.#", "#.#.#", "#.#.#", "#...#"], n: [".....", ".....", "####.", "#...#", "#...#", "#...#", "#...#"], o: [".....", ".....", ".###.", "#...#", "#...#", "#...#", ".###."], p: [".....", ".....", "####.", "#...#", "#...#", "####.", "#...."], q: [".....", ".....", ".####", "#...#", "#...#", ".####", "....#"], r: [".....", ".....", "#.##.", "##..#", "#....", "#....", "#...."], s: [".....", ".....", ".####", "#....", ".###.", "....#", "####."], t: ["..#..", "..#..", "#####", "..#..", "..#..", "..#..", "...##"], u: [".....", ".....", "#...#", "#...#", "#...#", "#...#", ".####"], v: [".....", ".....", "#...#", "#...#", "#...#", ".#.#.", "..#.."], w: [".....", ".....", "#...#", "#...#", "#.#.#", "#.#.#", ".#.#."], x: [".....", ".....", "#...#", ".#.#.", "..#..", ".#.#.", "#...#"], y: [".....", "#...#", "#...#", "#...#", "#...#", ".####", "....#"], z: [".....", ".....", "#####", "...#.", "..#..", ".#...", "#####"], }; function parseGlyph(rows: readonly string[]): boolean[] { const bits: boolean[] = []; for (const row of rows) { for (const ch of row) bits.push(ch === "#"); } return bits; } const GLYPHS = new Map( Object.entries(GLYPHS_ASCII).map(([ch, rows]) => [ch, parseGlyph(rows)]), ); export interface TextStyle { color?: string; scale?: number; opacity?: number; } /** The pixel size a string of `text` will occupy when drawn at the given scale. */ export function measureText(text: string, scale = 1): { width: number; height: number } { if (text.length === 0) return { width: 0, height: GLYPH_HEIGHT * scale }; const advance = (GLYPH_WIDTH + 1) * scale; return { width: text.length * advance - scale, height: GLYPH_HEIGHT * scale }; } /** * Draws `text` with its top-left corner at (x, y). Throws on any character * outside the supported set (see GLYPHS_ASCII above) rather than silently * dropping it — attribution text is a policy requirement, not decoration. */ export function drawText( canvas: Canvas, text: string, x: number, y: number, style: TextStyle = {}, ): void { const scale = style.scale ?? 1; const opacity = style.opacity ?? 1; const [r, g, b] = parseColor(style.color ?? "#000000"); let cursorX = x; for (const ch of text) { const glyph = GLYPHS.get(ch); if (!glyph) throw new Error(`unsupported character in text: ${JSON.stringify(ch)}`); for (let row = 0; row < GLYPH_HEIGHT; row++) { for (let col = 0; col < GLYPH_WIDTH; col++) { if (!glyph[row * GLYPH_WIDTH + col]) continue; for (let sy = 0; sy < scale; sy++) { for (let sx = 0; sx < scale; sx++) { canvas.blend(cursorX + col * scale + sx, y + row * scale + sy, r, g, b, opacity); } } } } cursorX += (GLYPH_WIDTH + 1) * scale; } }