storebound/tests/browser/local-fonts.browser.ts

216 lines
15 KiB
TypeScript

// 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<string, (cp: number) => 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<Set<number>> {
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<FontManifest> {
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')); }
});
});