const CHAR_ORDER = "abcdefghijklmnopqrstuvwxyz0123456789"; interface CharInfo { char: string; x: number; w: number; } const fontCache = new Map(); function hexPixel(data: Uint8ClampedArray, x: number, y: number, w: number): number { const i = (y * w + x) * 4; return (data[i] << 24) | (data[i + 1] << 16) | (data[i + 2] << 8) | data[i + 3]; } function isTransparent(data: Uint8ClampedArray, x: number, y: number, w: number): boolean { return data[(y * w + x) * 4 + 3] === 0; } function colEmpty(data: Uint8ClampedArray, x: number, w: number, h: number): boolean { for (let y = 0; y < h; y++) { if (!isTransparent(data, x, y, w)) return false; } return true; } function scanCharWidths(data: Uint8ClampedArray, sw: number, sh: number): CharInfo[] { const chars: CharInfo[] = []; let ci = 0; let x = 0; while (x < sw && ci < CHAR_ORDER.length) { // skip transparent columns while (x < sw && colEmpty(data, x, sw, sh)) x++; if (x >= sw) break; const start = x; // find end of character while (x < sw && !colEmpty(data, x, sw, sh)) x++; const end = x; const w = end - start; if (w > 0) { chars.push({ char: CHAR_ORDER[ci], x: start, w }); ci++; } } return chars; } export async function getFontInfo(font: string): Promise<{ chars: CharInfo[]; h: number; sheet: HTMLImageElement }> { const cached = fontCache.get(font); if (cached) return cached; const img = new Image(); img.crossOrigin = "anonymous"; await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error(`Failed to load sprite for ${font}`)); img.src = `/assets/fonts/sheets/${font}.gif`; }); const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext("2d")!; ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const chars = scanCharWidths(imageData.data, canvas.width, canvas.height); // measure total height by finding first/last non-empty row let top = canvas.height, bottom = 0; for (let y = 0; y < canvas.height; y++) { for (let x = 0; x < canvas.width; x++) { if (!isTransparent(imageData.data, x, y, canvas.width)) { top = Math.min(top, y); bottom = Math.max(bottom, y); break; } } } const h = bottom - top + 1; const result = { chars, h, sheet: img }; fontCache.set(font, result); return result; } export function measureText(chars: CharInfo[], h: number, text: string, spaceW: number): { totalW: number } { const map = new Map(chars.map((c) => [c.char, c])); let totalW = 0; for (const ch of text.toLowerCase()) { if (ch === " ") { totalW += spaceW; } else { const info = map.get(ch); totalW += info?.w ?? spaceW; } } return { totalW }; } export function renderToCanvas( chars: CharInfo[], h: number, sheet: HTMLImageElement, text: string, spaceW: number, dpr = 1, ): HTMLCanvasElement | null { const map = new Map(chars.map((c) => [c.char, c])); const safeText = text.toLowerCase(); let totalW = 0; for (const ch of safeText) { totalW += ch === " " ? spaceW : (map.get(ch)?.w ?? spaceW); } if (totalW === 0) return null; const canvas = document.createElement("canvas"); canvas.width = Math.round(totalW * dpr); canvas.height = Math.round(h * dpr); const ctx = canvas.getContext("2d"); if (!ctx) return null; ctx.scale(dpr, dpr); let x = 0; for (const ch of safeText) { if (ch === " ") { x += spaceW; } else { const info = map.get(ch); if (info) { ctx.drawImage(sheet, info.x, 0, info.w, sheet.naturalHeight, x, 0, info.w, h); x += info.w; } else { x += spaceW; } } } return canvas; }