// A game's own brand font as a local file in shots.fonts: validation, cmap coverage with listed + Noto fallbacks, // @font-face served in place, `shots check` (local + listed fallback pass, license warning) and the content hash that // invalidates the capture cache and stale renders. Fonts are tiny hand-made sfnt / WOFF2 files (cmap + name only). import { afterAll, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { brotliCompressSync } from 'node:zlib'; import { fontCodepoints, fontFamilyName } from '../../cli/shots/cmap'; import { type FaceJob, fontIssues, fontsCss, fontUsage, type FontManifest, GlyphError, planFonts } from '../../cli/shots/glyphs'; import { langTexts } from '../../cli/shots/fonts'; import { faceFilePath, fontsRoute, loadLocalFont, localByFamily, localFontIssues, resolveShotFonts, staleLocalFonts } from '../../cli/shots/local-fonts'; import { planWeb } from '../../cli/shots/capture-web'; import { validateConfig } from '../../cli/lib/config'; // ---- minimal font writer: an sfnt (or WOFF2) with a format 12 cmap and a name table ---- function cmapBytes(cps: number[]): Uint8Array { const sorted = [...new Set(cps)].sort((a, b) => a - b); const len = 16 + sorted.length * 12; const b = new DataView(new ArrayBuffer(12 + len)); b.setUint16(2, 1); b.setUint16(4, 3); b.setUint16(6, 10); b.setUint32(8, 12); b.setUint16(12, 12); b.setUint32(16, len); b.setUint32(24, sorted.length); sorted.forEach((c, i) => { b.setUint32(28 + i * 12, c); b.setUint32(32 + i * 12, c); b.setUint32(36 + i * 12, i + 1); }); return new Uint8Array(b.buffer); } function nameBytes(family: string): Uint8Array { const str = new Uint8Array(family.length * 2); [...family].forEach((c, i) => { str[i * 2] = 0; str[i * 2 + 1] = c.charCodeAt(0); }); const b = new DataView(new ArrayBuffer(6 + 12 + str.length)); b.setUint16(2, 1); b.setUint16(4, 18); b.setUint16(6, 3); b.setUint16(8, 1); b.setUint16(10, 0x409); b.setUint16(12, 1); b.setUint16(14, str.length); b.setUint16(16, 0); new Uint8Array(b.buffer).set(str, 18); return new Uint8Array(b.buffer); } function sfnt(cps: number[], family: string, sig = 0x00010000): Uint8Array { const tables: [string, Uint8Array][] = [['cmap', cmapBytes(cps)], ['name', nameBytes(family)]]; let off = 12 + tables.length * 16; const out = new Uint8Array(off + tables.reduce((n, [, t]) => n + t.length, 0)); const d = new DataView(out.buffer); d.setUint32(0, sig); d.setUint16(4, tables.length); tables.forEach(([tag, t], i) => { out.set([...tag].map(c => c.charCodeAt(0)), 12 + i * 16); d.setUint32(12 + i * 16 + 8, off); d.setUint32(12 + i * 16 + 12, t.length); out.set(t, off); off += t.length; }); return out; } function woff2(cps: number[], family: string): Uint8Array { const cmap = cmapBytes(cps), name = nameBytes(family); const stream = new Uint8Array(brotliCompressSync(Buffer.concat([cmap, name]))); const b128 = (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 = [0, ...b128(cmap.length), 5, ...b128(name.length)]; // known-table indices: 0 = cmap, 5 = name 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 cpsOf = (s: string): number[] => [...s].map(c => c.codePointAt(0)!); // "ER Display": ASCII, Latin-1 and Latin Extended-A without ą ę Ł ł; no Vietnamese (ơ ư, U+1EA0..) const ER = [...range(0x20, 0x7e), ...range(0xa0, 0xff), ...range(0x100, 0x17f).filter(c => ![0x105, 0x119, 0x141, 0x142].includes(c))]; const root = mkdtempSync(join(tmpdir(), 'sb-localfonts-')); afterAll(() => rmSync(root, { recursive: true, force: true })); const fontsDir = join(root, '.storebound', 'fonts'); mkdirSync(join(root, 'assets', 'fonts'), { recursive: true }); mkdirSync(fontsDir, { recursive: true }); writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2(ER, 'ER Display')); writeFileSync(join(root, 'assets/fonts/er-pl.ttf'), sfnt([0x105, 0x119, 0x141, 0x142], 'ER Polish')); const resolve = (p: string): string => join(root, p); describe('local font files: config and validation', () => { test('a path reads the family from the name table (WOFF2 and TTF); the object form sets family, weight, license', () => { expect(fontFamilyName(woff2(ER, 'ER Display'))).toBe('ER Display'); const a = loadLocalFont('assets/fonts/er-display.woff2', 'shots.fonts.title', resolve, root); expect(a.family).toBe('ER Display'); expect(a.local).toMatchObject({ path: 'assets/fonts/er-display.woff2', format: 'woff2' }); expect(a.local!.hash).toMatch(/^[0-9a-f]{16}$/); const b = loadLocalFont({ family: 'ER Pl', file: 'assets/fonts/er-pl.ttf', weight: 800, license: 'OFL-1.1' }, 'k', resolve, root); expect(b).toMatchObject({ family: 'ER Pl', local: { format: 'truetype', weight: '800', license: 'OFL-1.1' } }); expect(fontCodepoints(readFileSync(join(root, 'assets/fonts/er-display.woff2'))).size).toBe(ER.length); }); test('missing file, wrong extension, not a font, mismatched format', () => { writeFileSync(join(root, 'assets/fonts/bad.ttf'), new Uint8Array(64)); writeFileSync(join(root, 'assets/fonts/fake.ttf'), woff2(ER, 'X')); expect(() => loadLocalFont('assets/fonts/nope.ttf', 'shots.fonts.title', resolve, root)).toThrow('shots.fonts.title: assets/fonts/nope.ttf not found'); expect(() => loadLocalFont({ file: 'assets/fonts/er.svg' }, 'k', resolve, root)).toThrow('not a font file (TTF, OTF, WOFF or WOFF2)'); expect(() => loadLocalFont('assets/fonts/bad.ttf', 'k', resolve, root)).toThrow('unknown signature'); expect(() => loadLocalFont('assets/fonts/fake.ttf', 'k', resolve, root)).toThrow('is WOFF2 inside, but named .ttf'); }); test('validateConfig: font entry shapes', () => { const base = { app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }, languages: [{ id: 'en' }] }; const shots = (fonts: unknown) => ({ ...base, shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: 'fire', frames: [{ src: 'a' }] }], fonts } }); expect(validateConfig(shots({ title: 'assets/er.woff2', body: 'Montserrat', fallback: [{ family: 'ER Pl', file: 'a/er-pl.ttf', license: 'OFL-1.1' }, 'Noto Sans Display'] }))).toEqual([]); const errs = validateConfig(shots({ title: 'assets/er.svg', body: { family: 'B' }, fallback: [{ file: 'x.otf', style: 'oblique', weight: 'bold' }] })); expect(errs).toEqual([ 'shots.fonts.title: assets/er.svg: a font file must be TTF, OTF, WOFF or WOFF2', 'shots.fonts.body: a Google Fonts family, a font file path or { family, file, weight?, style?, license? }', 'shots.fonts.fallback[0].style: normal | italic', 'shots.fonts.fallback[0].weight: e.g. 400, 800 or "100 900"', ]); }); }); // Copy: ER Display covers tr / de / fr titles; Polish needs ą ę Ł ł, Vietnamese needs ơ ư ạ ... const COPY = { tr: { s1: { title: ["Taş Devri'nden", 'İleri!'] as [string, string], sub: 'Kasabaları yağmala' } }, de: { s1: { title: ['Größer', 'schöner'] as [string, string] } }, pl: { s1: { title: ['Gęsi łąka', 'Łódź'] as [string, string], sub: 'Szybka gra' } }, vi: { s1: { title: ['Chiến đấu', 'Thế giới'] as [string, string], sub: 'Trận nhanh' } }, }; const NOTO: Record boolean> = { Montserrat: () => true, // body: covers everything here 'Noto Sans Display': cp => cp < 0x250 || (cp >= 0x1ea0 && cp <= 0x1ef9), }; /** Getter like `shots fonts`: local files read in place, Google families faked. */ function getter(asked: FaceJob[]): (job: FaceJob) => Promise> { return async job => { asked.push(job); if (job.local) return fontCodepoints(readFileSync(join(root, job.local.path))); const f = NOTO[job.family] ?? (() => false); return new Set(cpsOf(job.chars).filter(f)); }; } async function plan(fallback: (string | { family?: string; file: string; license?: string })[] = [], asked: FaceJob[] = []): Promise { const sf = resolveShotFonts({ fonts: { title: 'assets/fonts/er-display.woff2', body: 'Montserrat', fallback } }, 'game', resolve, root); return planFonts({ langs: langTexts(COPY, [], 'STAR DASH'), title: sf.title.family, body: sf.body.family, titleAxis: null, bodyAxis: 'wght@100..900', fallbackTitleAxis: 'wght@800', chosen: sf.fallback.map(f => f.family), local: localByFamily([sf.title, sf.body, ...sf.fallback]) }, getter(asked)); } const fb = (m: FontManifest, lang: string) => m.langs[lang]!.fallbacks.filter(f => f.role === 'title').map(f => `${f.family}:${[...f.chars].join(' ')}`); describe('coverage of a local title font', () => { test('pl / vi titles: the characters ER Display lacks go to Noto Sans Display (automatic); tr / de need nothing', async () => { const asked: FaceJob[] = []; const m = await plan([], asked); expect(fb(m, 'pl')).toEqual(['Noto Sans Display:ę ł ą Ł']); expect(fb(m, 'vi')).toEqual(['Noto Sans Display:ế ấ ớ']); for (const l of ['tr', 'de']) expect(m.langs[l]!.fallbacks).toEqual([]); expect(m.faces['ER Display']).toMatchObject({ family: 'ER Display', kind: 'title', chosen: true, file: 'local/er-display.woff2', local: { path: 'assets/fonts/er-display.woff2', format: 'woff2' } }); expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(false); expect(asked.find(j => j.family === 'ER Display')!.local).toBeDefined(); // read in place, not downloaded }); test('listed fallbacks first (a local file), then Noto; chosen, so check passes', async () => { const m = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'OFL-1.1' }, 'Noto Sans Display']); expect(fb(m, 'pl')).toEqual(['ER Polish:ę ł ą Ł']); expect(fb(m, 'vi')).toEqual(['Noto Sans Display:ế ấ ớ']); expect(m.langs.pl!.title).toEqual(['ER Display', 'Store fb er-polish title']); expect(m.faces['Store fb er-polish title']).toMatchObject({ kind: 'fallback', chosen: true, file: 'local/er-polish.ttf', local: { license: 'OFL-1.1' } }); expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(true); }); test('nothing covers it: GlyphError names the local font and the fallbacks tried', async () => { const err = await planFonts({ langs: langTexts({ pl: { s1: { title: ['ą', 'x'] } } }, [], ''), title: 'ER Display', body: 'Montserrat', titleAxis: null, bodyAxis: null, fallbackTitleAxis: null, local: localByFamily([loadLocalFont('assets/fonts/er-display.woff2', 'k', resolve, root)]) }, async job => job.local ? fontCodepoints(readFileSync(join(root, job.local.path))) : new Set()).catch(e => e); expect(err).toBeInstanceOf(GlyphError); expect(err.message).toContain('pl (title): "ą" U+0105; tried ER Display, Noto Sans Display, Noto Sans'); }); }); describe('render and check with a local font', () => { test('fonts.css: @font-face for the local file, served in place at /fonts/local/... with its hash', async () => { const m = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'OFL-1.1' }]); const css = fontsCss(m); const h = m.faces['ER Display']!.local!.hash; expect(css).toContain(`@font-face { font-family: 'ER Display'; font-weight: 100 900; font-style: normal; font-synthesis: none; src: url(local/er-display.woff2?v=${h}) format('woff2'); }`); expect(css).toContain("src: url(local/er-polish.ttf?v="); expect(css).toContain("format('truetype')"); expect(css).toContain('/* ER Polish: local file assets/fonts/er-pl.ttf (license: OFL-1.1) */'); expect(fontsRoute(`local/er-display.woff2?v=${h}`, fontsDir, root, m)).toBe(join(root, 'assets/fonts/er-display.woff2')); expect(fontsRoute('local/other.ttf', fontsDir, root, m)).toBeNull(); expect(fontsRoute('local/../../x.ttf', fontsDir, root, m)).toBeNull(); expect(fontsRoute('fb-noto-sans-display-title.woff2', fontsDir, root, m)).toBe(join(fontsDir, 'fb-noto-sans-display-title.woff2')); expect(faceFilePath(fontsDir, root, m.faces['ER Display']!)).toBe(join(root, 'assets/fonts/er-display.woff2')); // nothing is copied into .storebound/fonts expect(Object.values(m.faces).filter(f => f.local).every(f => f.file.startsWith('local/'))).toBe(true); }); test('check: the local font and the listed fallback are not fallbacks the config did not choose; an automatic Noto is', async () => { const m = await plan([{ file: 'assets/fonts/er-pl.ttf' }]); const ok = fontUsage([ { el: 'h1 span:1', text: 'Gęsi łąka', faces: { 'ER Display': 'Gsika', 'Store fb er-polish title': 'ęłą' } }, { el: 'p.sub', text: 'Szybka gra', faces: { Montserrat: 'Szybkagr' } }, ], m.faces); expect(ok.used).toEqual({ 'ER Display': 5, 'ER Polish': 3, Montserrat: 8 }); expect(fontIssues(ok)).toEqual([]); const bad = fontUsage([{ el: 'h1 span:1', text: 'Chiến', faces: { 'ER Display': 'Chin', 'Store fb noto-sans-display title': 'ế' } }], m.faces); expect(fontIssues(bad).map(i => i.code)).toEqual(['font-fallback']); // license note: warn for the local file without one expect(localFontIssues(m).map(i => i.msg.split(' has')[0])).toEqual(['local font ER Display (assets/fonts/er-display.woff2)', 'local font ER Polish (assets/fonts/er-pl.ttf)']); const licensed = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'proprietary' }]); expect(localFontIssues(licensed).length).toBe(1); }); test('a changed font file changes the content hash: capture cache misses, render refuses the stale fonts.json', async () => { const m1 = await plan(); const cap = { source: 'web' as const, baseUrl: 'http://x', shots: { a: { path: '/a' } } }; const dims = { phone: { w: 10, h: 20, dpr: 1, bar: 0 }, tablet: { w: 10, h: 20, dpr: 1, bar: 0 } }; const job = (m: FontManifest) => planWeb(cap, { langs: ['tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b', fonts: fontsCss(m) })[0]!.hash; expect(staleLocalFonts(m1, root)).toEqual([]); writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2([...ER, 0x105], 'ER Display')); try { expect(staleLocalFonts(m1, root)).toEqual(['ER Display: assets/fonts/er-display.woff2 changed']); const m2 = await plan(); expect(m2.faces['ER Display']!.local!.hash).not.toBe(m1.faces['ER Display']!.local!.hash); expect(job(m2)).not.toBe(job(m1)); expect(fb(m2, 'pl')).toEqual(['Noto Sans Display:ę ł Ł']); // ą now in the font } finally { writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2(ER, 'ER Display')); } }); });