// Glyph coverage of the screenshot fonts: the pure-TS cmap reader (TTF / WOFF / WOFF2), the per-language plan with // automatic Noto fallbacks, and `shots check` errors for renders that needed a fallback or a system font. import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { brotliCompressSync, deflateSync } from 'node:zlib'; import { cmapTable, fontCodepoints, missingChars } from '../cli/shots/cmap'; import { attribute, type FaceJob, type MeasuredText, fontIssues, fontsCss, fontUsage, GlyphError, planFonts, roleTexts, uncoveredChars } from '../cli/shots/glyphs'; import { langTexts } from '../cli/shots/fonts'; import { allIssues, scanRenders } from '../cli/shots/report'; import { shotFonts } from '../cli/shots/lib'; // ---- tiny font builders (only a cmap table: enough for the reader) ---- function cmapBytes(cps: number[]): Uint8Array { const sorted = [...new Set(cps)].sort((a, b) => a - b); const bmp = sorted.filter(c => c < 0xffff); // format 4: one segment per code point (+ the 0xFFFF terminator), glyph id = index + 1 via idDelta const segs = [...bmp.map((c, i) => ({ s: c, e: c, d: (i + 1 - c) & 0xffff })), { s: 0xffff, e: 0xffff, d: 1 }]; const f4len = 16 + segs.length * 8; const f12len = 16 + sorted.length * 12; const b = new DataView(new ArrayBuffer(4 + 16 + f4len + f12len)); b.setUint16(0, 0); b.setUint16(2, 2); b.setUint16(4, 3); b.setUint16(6, 1); b.setUint32(8, 20); b.setUint16(12, 3); b.setUint16(14, 10); b.setUint32(16, 20 + f4len); let o = 20; b.setUint16(o, 4); b.setUint16(o + 2, f4len); b.setUint16(o + 6, segs.length * 2); segs.forEach((s, i) => { b.setUint16(o + 14 + i * 2, s.e); b.setUint16(o + 16 + segs.length * 2 + i * 2, s.s); b.setUint16(o + 16 + segs.length * 4 + i * 2, s.d); b.setUint16(o + 16 + segs.length * 6 + i * 2, 0); }); o += f4len; b.setUint16(o, 12); b.setUint32(o + 4, f12len); b.setUint32(o + 12, sorted.length); sorted.forEach((c, i) => { b.setUint32(o + 16 + i * 12, c); b.setUint32(o + 20 + i * 12, c); b.setUint32(o + 24 + i * 12, i + 1); }); return new Uint8Array(b.buffer); } function ttf(cps: number[]): Uint8Array { const cmap = cmapBytes(cps); const out = new Uint8Array(12 + 16 + cmap.length); const d = new DataView(out.buffer); d.setUint32(0, 0x00010000); d.setUint16(4, 1); out.set([...'cmap'].map(c => c.charCodeAt(0)), 12); d.setUint32(20, 28); d.setUint32(24, cmap.length); out.set(cmap, 28); return out; } function woff(cps: number[]): Uint8Array { const cmap = cmapBytes(cps), z = new Uint8Array(deflateSync(cmap)); const out = new Uint8Array(44 + 20 + z.length); const d = new DataView(out.buffer); out.set([...'wOFF'].map(c => c.charCodeAt(0)), 0); d.setUint32(4, 0x00010000); d.setUint32(8, out.length); d.setUint16(12, 1); out.set([...'cmap'].map(c => c.charCodeAt(0)), 44); d.setUint32(48, 64); d.setUint32(52, z.length); d.setUint32(56, cmap.length); out.set(z, 64); return out; } function woff2(cps: number[]): Uint8Array { // two tables: an untransformed 'head' stand-in (8 bytes) and cmap, both inside one brotli stream const cmap = cmapBytes(cps), head = new Uint8Array(8); const stream = new Uint8Array(brotliCompressSync(Buffer.concat([head, cmap]))); const base128 = (n: number): number[] => { const b = [n & 0x7f]; n = Math.floor(n / 128); while (n) { b.unshift(0x80 | (n & 0x7f)); n = Math.floor(n / 128); } return b; }; const dir = [1, ...base128(head.length), 0, ...base128(cmap.length)]; // flags: index 1 = head, 0 = cmap (transform version 0) const out = new Uint8Array(48 + dir.length + stream.length); const d = new DataView(out.buffer); out.set([...'wOF2'].map(c => c.charCodeAt(0)), 0); d.setUint32(4, 0x00010000); d.setUint32(8, out.length); d.setUint16(12, 2); d.setUint32(20, stream.length); out.set(dir, 48); out.set(stream, 48 + dir.length); return out; } const range = (a: number, b: number): number[] => Array.from({ length: b - a + 1 }, (_, i) => a + i); const ASCII = range(0x20, 0x7e); const cpsOf = (s: string): number[] => [...s].map(c => c.codePointAt(0)!); describe('cmap reader', () => { const want = [...ASCII, 0x15f, 0x11f, 0x1f600]; test('TTF, WOFF and WOFF2 give the same code points (format 4 + 12)', () => { for (const f of [ttf(want), woff(want), woff2(want)]) { const got = fontCodepoints(f); expect([...got].sort((a, b) => a - b)).toEqual([...want].sort((a, b) => a - b)); } expect(cmapTable(woff2(want)).length).toBe(cmapBytes(want).length); }); test('missingChars skips whitespace and joiners, reports each character once', () => { const cps = fontCodepoints(ttf(ASCII)); expect(missingChars("Taş Devri'nden şş‍", cps)).toEqual(['ş']); }); test('not a font: a clear error', () => { expect(() => fontCodepoints(new Uint8Array(64))).toThrow('not a font file'); }); }); // Copy in six languages whose glyphs an ASCII-only title / body font does not have. const COPY = { tr: { s1: { eyebrow: 'Bilgi', title: ["Taş Devri'nden", '2100\'e'] as [string, string], sub: 'Kasabaları yağmala' } }, pl: { s1: { title: ['Zażółć', 'gęślą jaźń'] as [string, string], sub: 'Łódź' } }, vi: { s1: { title: ['Chiến đấu', 'Thế giới'] as [string, string], sub: 'Trận chiến nhanh' } }, ru: { s1: { title: ['Битвы', 'за 2 минуты'] as [string, string], sub: 'Грабь деревни' } }, ar: { s1: { title: ['معارك', 'سريعة'] as [string, string], sub: '2 دقيقة' } }, ja: { s1: { title: ['2分', 'バトル'] as [string, string], sub: '村を襲え' } }, }; // Fake Google Fonts: what each family covers. const COVER: Record boolean> = { 'Lilita One': cp => cp < 0x7f || cp === 0x131 || cp === 0x141 || cp === 0x142, Montserrat: cp => cp < 0x7f, 'Noto Sans': cp => cp < 0x250 || (cp >= 0x400 && cp <= 0x4ff) || (cp >= 0x1ea0 && cp <= 0x1ef9), 'Noto Sans Display': cp => cp < 0x250 || (cp >= 0x1ea0 && cp <= 0x1ef9), 'Noto Sans Arabic': cp => cp >= 0x600 && cp <= 0x6ff, 'Noto Sans JP': cp => cp < 0x7f || (cp >= 0x3040 && cp <= 0x30ff) || (cp >= 0x4e00 && cp <= 0x9fff), }; function fakeGet(asked: FaceJob[]): (job: FaceJob) => Promise> { return async job => { asked.push(job); const f = COVER[job.family] ?? (() => false); return new Set(cpsOf(job.chars).filter(f)); }; } const input = (chosen?: string[]) => ({ langs: langTexts(COPY, ['en'], 'STAR DASH'), title: 'Lilita One', body: 'Montserrat', titleAxis: null, bodyAxis: 'wght@100..900', fallbackTitleAxis: 'wght@800', chosen, base: String.fromCharCode(...ASCII) }); describe('glyph coverage plan (shots fonts)', () => { test('tr / pl / vi / ru / ar / ja against fonts that lack the glyphs: flagged and an automatic Noto fallback added', async () => { const asked: FaceJob[] = []; const m = await planFonts(input(), fakeGet(asked)); // whole-line script faces stay as before expect(m.langs.ru!.title[0]).toBe('Store cyrgreek'); expect(m.langs.ar!.title[0]).toBe('Store arabic'); expect(m.langs.ja!.title[0]).toBe('Store jp'); // Latin Extended + Vietnamese titles: Noto Sans Display after Lilita One; body text: Noto Sans after Montserrat const fb = (lang: string, role: 'title' | 'body') => m.langs[lang]!.fallbacks.filter(f => f.role === role).map(f => `${f.family}:${f.chars}`); expect(fb('tr', 'title')).toEqual(['Noto Sans Display:ş']); expect(fb('tr', 'body')).toEqual(['Noto Sans:İığ']); // "BİLGİ" (Turkish upper case) + the subline expect(fb('pl', 'title')).toEqual(['Noto Sans Display:żóćęśąźń']); // ł: the title font has it expect(fb('pl', 'body')).toEqual(['Noto Sans:Łóź']); expect(fb('vi', 'title')).toEqual(['Noto Sans Display:ếđấớ']); expect(fb('vi', 'body')).toEqual(['Noto Sans:ậế']); expect(m.langs.tr!.title).toEqual(['Lilita One', 'Store fb noto-sans-display title']); expect(m.faces['Store fb noto-sans-display title']).toMatchObject({ family: 'Noto Sans Display', kind: 'fallback', chosen: false }); expect(asked.find(j => j.family === 'Noto Sans Display')!.axis).toBe('wght@800'); // ru / ar / ja need no fallback: their Noto script face covers the line, ASCII digits come from it or the font for (const l of ['ru', 'ar', 'ja', 'en']) expect(m.langs[l]!.fallbacks).toEqual([]); expect(fontsCss(m)).toContain("font-family: 'Store fb noto-sans-display title'; font-weight: 100 900; font-synthesis: none; src: url(fb-noto-sans-display-title.woff2)"); }); test('shots.fonts.fallback marks the family as chosen', async () => { const m = await planFonts(input(['Noto Sans Display', 'Noto Sans']), fakeGet([])); expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(true); expect(m.faces['Store fb noto-sans body']!.chosen).toBe(true); }); test('a character no fallback covers fails with the language and the characters', async () => { const copy = { ...COPY, tr: { s1: { title: ['Taş ', 'x'] as [string, string] } } }; const err = await planFonts({ ...input(), langs: langTexts(copy, [], '') }, fakeGet([])).catch(e => e); expect(err).toBeInstanceOf(GlyphError); expect(err.message).toContain('tr (title): "" U+E000; tried Lilita One, Noto Sans Display, Noto Sans'); expect((err as GlyphError).gaps.map(g => g.lang)).toEqual(['tr']); }); test('role texts: Turkish upper-case eyebrow, badges split between title and body', () => { const t = roleTexts('tr', { a: { eyebrow: 'bilgi', title: ['A', 'B'], badges: [['9x', 'şarj']] } }, 'Game'); expect(t.title).toContain('9x'); expect(t.body).toContain('BİLGİ'); expect(t.body).toContain('şarj'); expect(uncoveredChars(t.body, [new Set(ASCII)])).toEqual(['İ', 'ş']); }); test('defaults cover Latin Extended and Vietnamese (game: Paytone One + Montserrat)', () => { expect(shotFonts(undefined, 'game')).toEqual({ title: 'Paytone One', body: 'Montserrat', titleVariable: false }); }); }); describe('render report fonts -> shots check', () => { const faces = { 'Lilita One': { family: 'Lilita One', file: 'title.woff2', kind: 'title' as const, chosen: true }, Montserrat: { family: 'Montserrat', file: 'body.woff2', kind: 'body' as const, chosen: true }, 'Store fb noto-sans-display title': { family: 'Noto Sans Display', file: 'fb.woff2', kind: 'fallback' as const, role: 'title' as const, chosen: false }, }; const measured: MeasuredText[] = [ { el: 'h1 span:1', text: "Taş Devri'nden", faces: { 'Lilita One': "TaDevri'nden", 'Store fb noto-sans-display title': 'ş' } }, { el: 'p.sub', text: 'Kasabaları yağmala', faces: { Montserrat: 'Kasbalrıyğm' } }, { el: 'div.eyebrow', text: 'X', faces: { '(system)': '✪' } }, ]; test('attribute: first face of the stack whose probe drew with a web font, else (system)', () => { const els = [{ el: 'h1 span:1', text: 'Taş ✪', stack: ['Lilita One', 'Store fb noto-sans-display title'], chars: ['T', 'a', 'ş', '✪'], probe: { 'Lilita One|T': 0, 'Store fb noto-sans-display title|T': 1, 'Lilita One|a': 2, 'Store fb noto-sans-display title|a': 3, 'Lilita One|ş': 4, 'Store fb noto-sans-display title|ş': 5, 'Lilita One|✪': 6, 'Store fb noto-sans-display title|✪': 7 } }]; expect(attribute(els, [true, true, true, true, false, true, false, false])).toEqual([ { el: 'h1 span:1', text: 'Taş ✪', faces: { 'Lilita One': 'Ta', 'Store fb noto-sans-display title': 'ş', '(system)': '✪' } }]); }); test('fallback the config did not choose and system-font glyphs are errors', () => { const u = fontUsage(measured, faces); expect(u.used).toEqual({ 'Lilita One': 12, 'Noto Sans Display': 1, Montserrat: 11, '(system)': 1 }); const issues = fontIssues(u); expect(issues.map(i => i.code)).toEqual(['font-fallback', 'glyph-missing']); expect(issues[0]!.msg).toContain('ş drawn with Noto Sans Display'); // a chosen fallback is fine const ok = fontUsage(measured.slice(0, 2), { ...faces, 'Store fb noto-sans-display title': { ...faces['Store fb noto-sans-display title'], chosen: true } }); expect(fontIssues(ok)).toEqual([]); }); test('scanRenders: ERROR per image, warn for renders without font usage', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-glyphs-')); try { mkdirSync(join(dir, 'tr', 'ios69'), { recursive: true }); const png = (w: number, h: number) => { const b = new Uint8Array(33); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); const d = new DataView(b.buffer); d.setUint32(16, w); d.setUint32(20, h); b[24] = 8; b[25] = 2; return b; }; for (const n of ['01', '02', '03']) writeFileSync(join(dir, 'tr', 'ios69', `${n}.png`), png(1320, 2868)); writeFileSync(join(dir, 'report.json'), JSON.stringify({ 'tr/ios69/01.png': { overflow: [], fonts: fontUsage(measured.slice(0, 2), faces) }, 'tr/ios69/02.png': { overflow: [], fonts: { used: { 'Paytone One': 20 }, fallback: [], missing: [] } }, 'tr/ios69/03.png': { overflow: [] }, })); const got = allIssues(scanRenders(dir, { langs: ['tr'], sizes: ['ios69'], probe: () => null })).map(x => `${x.where} ${x.issue.level} ${x.issue.code}`); expect(got).toEqual(['tr/ios69/01.png error font-fallback', 'tr/ios69/03.png warn fonts']); } finally { rmSync(dir, { recursive: true, force: true }); } }); });