Lilita One lacks most of Latin Extended-A (Turkish s-cedilla, g-breve, dotted I ...), so Turkish titles were drawn with a silent browser fallback while shots check passed. shots fonts now reads the cmap of every face (pure TS, TTF/OTF/WOFF/WOFF2), adds a Noto fallback per missing character or fails with language + characters; render measures which face drew each character (CDP probes) and records it in report.json; check errors on fallbacks the config did not choose (shots.fonts.fallback) and on system-font glyphs. Default game title font: Paytone One.
220 lines
13 KiB
TypeScript
220 lines
13 KiB
TypeScript
// 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<string, (cp: number) => 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<Set<number>> {
|
|
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 }); }
|
|
});
|
|
});
|