storebound/cli/shots/glyphs.ts

356 lines
23 KiB
TypeScript

// Glyph coverage of the screenshot fonts, per language and role (title / body), and the automatic Noto fallbacks.
// `shots fonts` plans the faces with planFonts() (downloads through an injected getter, so tests run offline) and
// writes .storebound/fonts/fonts.json; `shots render` re-checks coverage against the local files before rendering
// (uncoveredChars) and measures, in the page, which face drew every character (measureFonts). `shots check` turns a
// glyph drawn by a fallback the config did not choose, or by a system font, into an ERROR.
//
// Default stack per script (the template defaults, documented in docs/store-art.md and the store-screenshots skill):
// Latin incl. Latin Extended-A / B and Vietnamese, Cyrillic, Greek: the configured title / body font
// (defaults: game Paytone One + Montserrat, app Inter + Inter; all four cover Latin Extended and Vietnamese;
// Inter and Montserrat cover Cyrillic too). A Cyrillic or Greek line switches to Noto Sans as a whole.
// Arabic / Hebrew / Devanagari / Bengali / Thai: Noto Sans Arabic / Hebrew / Devanagari / Bengali / Thai (whole line).
// Japanese / Korean / Chinese: Noto Sans JP / KR / SC (TC for zh-Hant, TW, HK) (whole line).
// Any character the stack lacks: Noto Sans Display (titles) or Noto Sans (body text) for Latin / Cyrillic / Greek,
// Noto Sans <Script> for other scripts, then Noto Sans Symbols 2, Noto Sans Symbols, Noto Sans Math and Noto Color
// Emoji, added per character (logged, and an ERROR in `shots check` unless listed in shots.fonts.fallback).
import type { SceneCopy } from './lib';
import { scriptFont } from './lib';
import { needsGlyph } from './cmap';
import { type LocalFontInfo, localFile, slug } from './local-fonts';
export type Role = 'title' | 'body';
export interface RoleTexts { title: string; body: string }
/** The text each font role draws for one language: titles, badge values and the app name (feature graphic) use the
* title font; eyebrows (also upper-cased, as the template shows them), sublines, badge labels and scene numbers the
* body font. */
export function roleTexts(lang: string, c: Record<string, SceneCopy> | undefined, appName = ''): RoleTexts {
const title: string[] = [appName], body: string[] = ['0123456789'];
for (const s of Object.values(c ?? {})) {
title.push(...(s.title ?? []).filter(Boolean));
body.push(s.eyebrow ?? '', (s.eyebrow ?? '').toLocaleUpperCase(lang), s.sub ?? '');
for (const [big, small] of s.badges ?? []) { title.push(big ?? ''); body.push(small ?? ''); }
}
return { title: title.join('').normalize('NFC'), body: body.join('').normalize('NFC') };
}
/** Unique characters of a text that need a glyph, in first-seen order. */
export const glyphChars = (text: string): string[] => [...new Set([...text.normalize('NFC')].filter(needsGlyph))];
/** Characters of `text` that none of the faces covers. */
export function uncoveredChars(text: string, faces: Set<number>[]): string[] {
return glyphChars(text).filter(ch => { const cp = ch.codePointAt(0)!; return !faces.some(f => f.has(cp)); });
}
const NOTO_SCRIPTS = ['Arabic', 'Hebrew', 'Devanagari', 'Bengali', 'Thai', 'Armenian', 'Georgian', 'Ethiopic', 'Tamil', 'Telugu',
'Kannada', 'Malayalam', 'Gujarati', 'Gurmukhi', 'Oriya', 'Khmer', 'Lao', 'Myanmar', 'Sinhala', 'Thaana', 'Syriac'];
const SYMBOLS = ['Noto Sans Symbols 2', 'Noto Sans Symbols', 'Noto Sans Math', 'Noto Color Emoji'];
/** Noto families to try, in order, for a character the configured stack lacks. */
export function fallbackFamilies(ch: string, lang: string, role: Role): string[] {
const latin = role === 'title' ? ['Noto Sans Display', 'Noto Sans'] : ['Noto Sans'];
if (/\p{Extended_Pictographic}/u.test(ch) && !/[©®™]/u.test(ch)) return SYMBOLS;
if (/[\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}]/u.test(ch)) return latin;
if (/\p{Script=Hangul}/u.test(ch)) return ['Noto Sans KR'];
if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(ch)) return ['Noto Sans JP'];
if (/\p{Script=Han}/u.test(ch)) return /Hant|TW|HK/.test(lang) ? ['Noto Sans TC', 'Noto Sans JP'] : ['Noto Sans SC', 'Noto Sans JP'];
for (const s of NOTO_SCRIPTS) if (new RegExp(`\\p{Script=${s}}`, 'u').test(ch)) return [`Noto Sans ${s}`];
return [...latin, ...SYMBOLS];
}
/** Global order the fallback families are tried in (every per-character list above is a subsequence of it). */
export const FALLBACK_ORDER = ['Noto Sans Display', 'Noto Sans', 'Noto Sans KR', 'Noto Sans JP', 'Noto Sans SC', 'Noto Sans TC',
...NOTO_SCRIPTS.map(s => `Noto Sans ${s}`), ...SYMBOLS];
export type FaceKind = 'title' | 'body' | 'script' | 'fallback';
/** One downloaded face: `face` is the @font-face family name the templates use. axis: css2 axis spec ("wght@800",
* "wght@100..900") or null for the font's default instance. */
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo;
/** Weights the templates use for this face's role (title / body): the getter fetches them (weights.ts fetchFaces). */
weights?: number[] }
/** What a getter returns: the covered code points, and (several weights downloaded) the files with their weight. */
export type FaceResult = Set<number> | { cps: Set<number>; files: FaceFile[] };
export interface FaceFile { file: string; weight: string }
export interface LangFonts { title: string[]; body: string[]; fallbacks: { role: Role; face: string; family: string; chars: string }[] }
export interface FontManifest {
v: 1;
/** local: a local font file (shots.fonts entry with a path), read in place, never copied; file = its served path. */
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo;
/** Downloaded files per weight ("700", "200 800"); none = one file for every weight (older manifests). */
files?: FaceFile[] }>;
langs: Record<string, LangFonts>;
}
export interface PlanInput {
langs: Record<string, RoleTexts>;
title: string; body: string;
/** css2 axis for the title / body family (null = default instance). */
titleAxis: string | null; bodyAxis: string | null;
/** Weights the templates use per role (weights.ts styleWeights); set = fetch these instead of titleAxis / bodyAxis. */
titleWeights?: number[]; bodyWeights?: number[];
/** Weight axis for automatic title fallbacks (game titles are heavy display faces: "wght@800"). */
fallbackTitleAxis: string | null;
/** shots.fonts.fallback: families the owner accepts as fallbacks, in order. Families that are not one of the automatic
* Noto fallbacks (another Google family, a local file) are tried first, for every character the stack lacks. */
chosen?: string[];
/** Local font files by family (title, body or fallback). */
local?: Record<string, LocalFontInfo>;
/** Extra characters for the title / body downloads (ASCII, so later copy edits rarely need a new download). */
base?: string;
}
/** No font covers these characters: listed per language and role. */
export class GlyphError extends Error {
constructor(public readonly gaps: { lang: string; role: Role; chars: string[]; tried: string[] }[]) {
super('no font covers these characters (pick fonts that do, or change the copy):\n' + gaps.map(g =>
` ${g.lang} (${g.role}): ${g.chars.map(c => `"${c}" U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`).join(', ')}; tried ${g.tried.join(', ')}`).join('\n'));
}
}
/** Faces for every language's copy: title + body, one Noto face per non-Latin script (whole lines, as before), and an
* automatic Noto fallback for each character the stack still lacks. `get` downloads a face (only the listed chars)
* and returns the code points its cmap covers. Throws GlyphError when no fallback covers a character. */
export async function planFonts(input: PlanInput, get0: (job: FaceJob) => Promise<FaceResult>): Promise<FontManifest> {
const files = new Map<string, FaceFile[]>();
const get = async (job: FaceJob): Promise<Set<number>> => {
const r = await get0(job);
if (r instanceof Set) return r;
files.set(job.face, r.files);
return r.cps;
};
const chosen = new Set((input.chosen ?? []).map(s => s.toLowerCase()));
const local = input.local ?? {};
const extra = [...new Set(input.chosen ?? [])].filter(f => !FALLBACK_ORDER.some(n => n.toLowerCase() === f.toLowerCase()));
const order = [...extra, ...FALLBACK_ORDER];
const fileOf = (family: string, google: string): string => local[family] ? localFile(family, local[family]!) : google;
const loc = (family: string): { local?: LocalFontInfo } => (local[family] ? { local: local[family] } : {});
const base = input.base ?? '';
const scripts = new Map<string, { family: string; chars: Set<string> }>();
const langScript = new Map<string, string | null>();
const roleChars: Record<Role, Set<string>> = { title: new Set(base), body: new Set(base) };
for (const [lang, t] of Object.entries(input.langs)) {
const s = scriptFont(lang, t.title + t.body);
langScript.set(lang, s ? `Store ${s.slug}` : null);
if (s) {
const cur = scripts.get(s.slug) ?? { family: s.family, chars: new Set(base) };
for (const ch of t.title + t.body) cur.chars.add(ch);
scripts.set(s.slug, cur);
}
// the title / body faces get every Latin / common character (and every character of Latin-only copy)
for (const r of ['title', 'body'] as const) for (const ch of t[r]) if (!s || !/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(ch)) roleChars[r].add(ch);
}
const sorted = (s: Iterable<string>): string => [...new Set(s)].sort().join('');
const faces: FontManifest['faces'] = {};
const cps = new Map<string, Set<number>>();
const add = async (job: FaceJob, isChosen: boolean): Promise<void> => {
cps.set(job.face, await get(job));
if (job.kind === 'fallback' && ![...job.chars].some(ch => cps.get(job.face)!.has(ch.codePointAt(0)!))) return; // covers none: not a face
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}), ...(files.has(job.face) ? { files: files.get(job.face) } : {}) };
};
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title), ...(input.titleWeights ? { weights: input.titleWeights } : {}) }, true);
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body), ...(input.bodyWeights ? { weights: input.bodyWeights } : {}) }, true);
else { // same family: one download (title axis) with both roles' characters
const t = faces[input.title]!;
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title),
...(input.titleWeights ? { weights: [...new Set([...input.titleWeights, ...(input.bodyWeights ?? [])])].sort((a, b) => a - b) } : {}) }));
if (files.has(input.title)) t.files = files.get(input.title);
}
for (const [s, v] of scripts) await add({ face: `Store ${s}`, family: v.family, file: `${s}.woff2`, kind: 'script', axis: 'wght@100..900', chars: sorted(v.chars) }, true);
// per language and role: the stack, and what it lacks
const langs: FontManifest['langs'] = {};
const pending: { lang: string; role: Role; ch: string; tries: string[] }[] = [];
for (const [lang, t] of Object.entries(input.langs)) {
const x = langScript.get(lang);
langs[lang] = { title: [...(x ? [x] : []), input.title], body: [...(x ? [x] : []), input.body], fallbacks: [] };
for (const role of ['title', 'body'] as const) {
const stack = langs[lang]![role].map(f => cps.get(f)!);
for (const ch of uncoveredChars(t[role], stack)) pending.push({ lang, role, ch, tries: [...extra, ...fallbackFamilies(ch, lang, role)] });
}
}
const gaps = new Map<string, { lang: string; role: Role; chars: string[]; tried: string[] }>();
const hits = new Map<string, Map<string, Set<string>>>(); // lang|role -> face -> chars
for (const role of ['title', 'body'] as const) for (const family of order) {
const want = pending.filter(p => p.role === role && p.tries.includes(family));
if (!want.length) continue;
const face = `Store fb ${slug(family)} ${role}`;
const axis = family === 'Noto Color Emoji' ? null : role === 'title' ? input.fallbackTitleAxis : 'wght@100..900';
await add({ face, family, file: fileOf(family, `fb-${slug(family)}-${role}.woff2`), kind: 'fallback', role, axis, chars: sorted(want.map(p => p.ch)), ...loc(family) }, chosen.has(family.toLowerCase()));
const got = cps.get(face)!;
for (const p of want) {
p.tries = p.tries.filter(f => f !== family);
if (!got.has(p.ch.codePointAt(0)!)) continue;
const k = `${p.lang}|${role}`;
const m = hits.get(k) ?? new Map<string, Set<string>>();
m.set(face, (m.get(face) ?? new Set()).add(p.ch));
hits.set(k, m);
p.tries = [];
(p as { done?: boolean }).done = true;
}
}
for (const p of pending) {
if ((p as { done?: boolean }).done) continue;
const k = `${p.lang}|${p.role}`;
const g = gaps.get(k) ?? { lang: p.lang, role: p.role, chars: [], tried: [...langs[p.lang]![p.role].map(f => faces[f]?.family ?? f), ...extra, ...fallbackFamilies(p.ch, p.lang, p.role)] };
g.chars.push(p.ch);
gaps.set(k, g);
}
if (gaps.size) throw new GlyphError([...gaps.values()]);
for (const [k, m] of hits) {
const [lang, role] = k.split('|') as [string, Role];
for (const face of [...m.keys()].sort((a, b) => order.indexOf(faces[a]!.family) - order.indexOf(faces[b]!.family))) {
langs[lang]![role].push(face);
langs[lang]!.fallbacks.push({ role, face, family: faces[face]!.family, chars: [...m.get(face)!].join('') });
}
}
return { v: 1, faces, langs };
}
/** fonts.css for a manifest (every face covers all weights: static faces are never synthesised bolder). */
export function fontsCss(m: FontManifest): string {
const seen = new Set<string>();
let css = '/* Generated by storebound shots fonts (Google Fonts, SIL Open Font License 1.1), subset to the copy. */\n';
for (const [face, f] of Object.entries(m.faces)) {
if (seen.has(face)) continue;
seen.add(face);
if (f.local) { // the project's own font file, served in place; ?v= changes with its content
const l = f.local;
css += `/* ${f.family}: local file ${l.path} (${l.license ? 'license: ' + l.license.replace(/\*\//g, '') : 'no license note'}) */\n`;
css += `@font-face { font-family: '${face}'; font-weight: ${l.weight ?? '100 900'}; font-style: ${l.style ?? 'normal'}; font-synthesis: none; src: url(${f.file}?v=${l.hash}) format('${l.format}'); }\n`;
continue;
}
if (f.files?.length) { // one file per downloaded weight: a weight the family lacks is drawn at the nearest one (never faked)
for (const x of f.files) css += `@font-face { font-family: '${face}'; font-weight: ${x.weight}; font-synthesis: none; src: url(${x.file}) format('woff2'); }\n`;
continue;
}
css += `@font-face { font-family: '${face}'; font-weight: 100 900; font-synthesis: none; src: url(${f.file}) format('woff2'); }\n`;
}
return css;
}
// ---- render time ------------------------------------------------------------------------------------------------
/** Per text element of a rendered page: which face drew which characters ("(system)" = none of ours). */
export interface MeasuredText { el: string; text: string; faces: Record<string, string> }
/** Runs in the page (`(${COLLECT_TEXT})(faceNames)`): every element with its own text whose font stack starts with one
* of our faces, with the characters it shows (after text-transform) and its stack of our faces; plus one hidden probe
* span per (face, weight, style, character) in #sb-glyph-probes, whose fonts `measureFonts` reads over CDP. Marks and
* whitespace are skipped (checked offline against the cmaps). */
export const COLLECT_TEXT = `(ours) => {
const OURS = new Set(ours);
const label = el => {
const me = el.tagName.toLowerCase() + (el.classList.length ? '.' + [...el.classList].join('.') : '');
if (!/^(span|b|small|i|em|strong)$/.test(el.tagName.toLowerCase()) || !el.parentElement) return me;
const p = el.parentElement, sib = [...p.children].filter(c => c.tagName === el.tagName);
return label(p) + ' ' + me + (sib.length > 1 ? ':' + (sib.indexOf(el) + 1) : '');
};
const lang = document.documentElement.lang || undefined;
const els = [], probes = [], seen = new Map();
for (const el of document.body.querySelectorAll('*')) {
if (/^(SCRIPT|STYLE|SVG)$/i.test(el.tagName) || el.closest('#sb-glyph-probes')) continue;
let text = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.data).join('').normalize('NFC');
if (!text.trim()) continue;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
const fams = cs.fontFamily.split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
if (!OURS.has(fams[0])) continue;
const stack = [];
for (const f of fams) { if (!OURS.has(f)) break; stack.push(f); }
if (cs.textTransform === 'uppercase') text = text.toLocaleUpperCase(lang);
else if (cs.textTransform === 'lowercase') text = text.toLocaleLowerCase(lang);
const chars = [...new Set([...text])].filter(ch => !/^[\\s\\p{Cc}\\p{Cf}\\p{M}\\u{FE00}-\\u{FE0F}]$/u.test(ch));
const probe = {};
for (const ch of chars) for (const face of stack) {
const k = [face, cs.fontWeight, cs.fontStyle, ch].join('|');
if (!seen.has(k)) { seen.set(k, probes.length); probes.push({ face, weight: cs.fontWeight, style: cs.fontStyle, ch }); }
probe[face + '|' + ch] = seen.get(k);
}
els.push({ el: label(el), text: text.trim().slice(0, 60), stack, chars, probe });
}
const box = document.createElement('div');
box.id = 'sb-glyph-probes';
box.style.cssText = 'position:absolute;left:-20000px;top:0;white-space:pre';
for (const p of probes) {
const s = document.createElement('span');
// display:block: one character per line box. Inline probe spans side by side are shaped as one run, so "f" + "i"
// became the fi ligature, the "i" span reported no glyph of ours and was blamed on a system font.
s.style.cssText = 'display:block;font-size:32px;font-family:"' + p.face.replace(/"/g, '') + '";font-weight:' + p.weight + ';font-style:' + p.style;
s.textContent = p.ch;
box.append(s);
}
document.body.append(box);
return { els, probes: probes.length };
}`;
export interface CollectedText { el: string; text: string; stack: string[]; chars: string[]; probe: Record<string, number> }
/** Which face drew each character: the first face of the element's stack whose probe span used a web font (the probe
* span names that face only, so a web font in its platform fonts can only be that face). Pure. */
export function attribute(els: CollectedText[], covered: boolean[]): MeasuredText[] {
return els.map(e => {
const faces: Record<string, string> = {};
for (const ch of e.chars) {
const f = e.stack.find(face => covered[e.probe[face + '|' + ch]!]) ?? '(system)';
faces[f] = (faces[f] ?? '') + ch;
}
return { el: e.el, text: e.text, faces };
});
}
/** Measures a rendered page (Playwright chromium page): collects the text, then asks Chrome over CDP
* (CSS.getPlatformFontsForNode) which fonts drew each probe. Removes the probes afterwards. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function measureFonts(page: any, ours: string[]): Promise<MeasuredText[]> {
const { els } = await page.evaluate(`(${COLLECT_TEXT})(${JSON.stringify(ours)})`) as { els: CollectedText[] };
await page.evaluate('document.fonts.ready.then(() => 0)');
const cdp = await page.context().newCDPSession(page);
try {
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '#sb-glyph-probes > span' }) as { nodeIds: number[] };
const covered: boolean[] = [];
for (const nodeId of nodeIds) {
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId }) as { fonts: { isCustomFont: boolean; glyphCount: number }[] };
covered.push(fonts.some(f => f.isCustomFont && f.glyphCount > 0));
}
return attribute(els, covered);
} finally {
await cdp.detach().catch(() => {});
await page.evaluate("document.getElementById('sb-glyph-probes')?.remove()");
}
}
/** Font usage of one rendered image, as stored in report.json. fallback = drawn by a face the config did not choose;
* missing = drawn by a system font (no face of ours has the glyph, or the font file did not load). */
export interface FontUsage {
used: Record<string, number>;
fallback: { el: string; text: string; face: string; family: string; chars: string }[];
missing: { el: string; text: string; chars: string }[];
}
/** Classifies what the page measured: faces of kind title / body / script and chosen fallbacks are fine. */
export function fontUsage(measured: MeasuredText[], faces: FontManifest['faces']): FontUsage {
const u: FontUsage = { used: {}, fallback: [], missing: [] };
for (const m of measured) for (const [face, chars] of Object.entries(m.faces)) {
const n = [...chars].length;
const fam = face === '(system)' ? '(system)' : faces[face]?.family ?? face;
u.used[fam] = (u.used[fam] ?? 0) + n;
if (face === '(system)') u.missing.push({ el: m.el, text: m.text, chars });
else if (faces[face] && !faces[face]!.chosen) u.fallback.push({ el: m.el, text: m.text, face, family: faces[face]!.family, chars });
}
return u;
}
/** check messages for an image's font usage (all errors). */
export function fontIssues(u: FontUsage | undefined): { level: 'error'; code: 'font-fallback' | 'glyph-missing'; msg: string }[] {
if (!u) return [];
const sp = (s: string): string => [...s].join(' ');
return [
...u.fallback.map(f => ({ level: 'error' as const, code: 'font-fallback' as const,
msg: `${f.el} "${f.text}": ${sp(f.chars)} drawn with ${f.family}, an automatic fallback the config did not choose (use a title / body font that covers them, or add '${f.family}' to shots.fonts.fallback, then shots fonts + render)` })),
...u.missing.map(f => ({ level: 'error' as const, code: 'glyph-missing' as const,
msg: `${f.el} "${f.text}": ${sp(f.chars)} drawn with a system font (no downloaded font has them or the fonts did not load: storebound shots fonts, then render again)` })),
];
}