// Downloads the fonts the screenshot template needs, subset by Google Fonts (css2 `text=`) to exactly the characters // your copy uses, so even CJK stays small. `text=` pulls the glyphs from every subset the family has (latin, latin-ext, // vietnamese, cyrillic, greek ...), so a missing glyph means the family itself lacks it. // Then the glyph coverage check: every character of every language's copy (copy.json languages + config languages) // is looked up in the cmap of the faces that draw it (title font: titles, badge values, app name; body font: eyebrows, // sublines, badge labels; plus the Noto face of a non-Latin script). A character the stack lacks gets an automatic // Noto fallback (logged; `shots check` errors on renders that used it unless shots.fonts.fallback lists it); a // character no fallback covers fails with the language and the characters. // storebound shots fonts [--lang en,tr] [--json] (--lang limits the report; the manifest keeps all languages) (re-run after editing the copy or the fonts) // Out: /.storebound/fonts/*.woff2 + fonts.css + fonts.json (faces and the per-language stacks, read by render). // The Google fonts are SIL Open Font License 1.1. A local font file in shots.fonts (local-fonts.ts) gets the same cmap // check, is read in place (never copied) and recorded with its path, sha256 and license note. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { shotsStyle } from '../lib/config'; import type { CopyFile } from './lib'; import { localByFamily, resolveShotFonts } from './local-fonts'; import { fontCodepoints } from './cmap'; import { SHOTS_PAGES } from '../lib/embedded'; import { fetchFaces, localWeightGap, serves, styleWeights } from './weights'; import { type FaceJob, type FaceResult, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs'; const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + 'ยท'; export async function fetchSubset(family: string, chars: string, axis: string | null): Promise { return (await fetchFaces(family, chars, { axis }))[0]!.bin; } /** Weights the style's templates (screenshot + feature graphic) ask for, per role. */ export function weightsFor(style: 'game' | 'app'): { title: number[]; body: number[] } { return styleWeights(style === 'app' ? [SHOTS_PAGES['template-app.html']!, SHOTS_PAGES['feature-app.html']!] : [SHOTS_PAGES['template.html']!, SHOTS_PAGES['feature.html']!]); } /** Per-language role texts for every language of the copy and the config. */ export function langTexts(copy: CopyFile, langs: string[], appName: string): Record { const out: Record = {}; for (const lang of [...new Set([...Object.keys(copy), ...langs])]) out[lang] = roleTexts(lang, copy[lang], appName); return out; } export const fontsDirOf = (root: string): string => join(root, '.storebound', 'fonts'); export function readManifest(dir: string): FontManifest | null { try { return JSON.parse(readFileSync(join(dir, 'fonts.json'), 'utf8')) as FontManifest; } catch { return null; } } export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const shots = need(config.shots, 'shots'); const copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; const dir = fontsDirOf(root); mkdirSync(dir, { recursive: true }); const style = shotsStyle(config); const sf = resolveShotFonts(shots, style, resolve, root); const W = weightsFor(style); const title = sf.title.family, body = sf.body.family; const local = localByFamily([sf.title, sf.body, ...sf.fallback]); const texts = langTexts(copy, config.languages.map(l => l.id), config.app.name); const sizes: string[] = []; const manifest = await planFonts({ langs: texts, title, body, titleAxis: null, bodyAxis: null, titleWeights: W.title, bodyWeights: W.body, fallbackTitleAxis: style === 'app' ? 'wght@100..900' : 'wght@800', chosen: sf.fallback.map(f => f.family), base: ASCII, local, }, async (job: FaceJob): Promise => { if (job.local) { // read in place: the whole file's cmap const bin = new Uint8Array(readFileSync(join(root, job.local.path))); const cps = fontCodepoints(bin); const line = `${job.local.path} ${job.family}, ${(bin.byteLength / 1024).toFixed(1)} KB, ${cps.size} in the cmap, sha256 ${job.local.hash}, license: ${job.local.license ?? 'none noted (add license to the shots.fonts entry)'}`; if (!sizes.includes(line)) sizes.push(line); return cps; } let got: { weight: string; bin: ArrayBuffer }[]; try { got = await fetchFaces(job.family, job.chars, job.weights ? { weights: job.weights } : { axis: job.axis }); } catch (e) { if (job.kind !== 'fallback') throw e; log('shots', `${job.family} has none of ${[...job.chars].join(' ')} (${(e as Error).message.split(' for ')[0]}), trying the next fallback`); return new Set(); } const bin = new Uint8Array(got[0]!.bin); writeFileSync(join(dir, job.file), bin); // the cmap / coverage file (every weight has the same subset) const cps = fontCodepoints(bin); const weights = got.map(g => g.weight).join(', '); sizes.push(`${job.file.padEnd(28)} ${job.family.padEnd(22)} ${(got.reduce((t, g) => t + g.bin.byteLength, 0) / 1024).toFixed(1)} KB, weight ${weights}, ${[...job.chars].length} chars asked, ${cps.size} in the cmap`); if (job.weights) { const files = got.length === 1 ? [{ file: job.file, weight: got[0]!.weight }] : got.map(g => { const file = job.file.replace(/\.woff2$/, `-${g.weight.replace(/ /g, '-')}.woff2`); writeFileSync(join(dir, file), new Uint8Array(g.bin)); return { file, weight: g.weight }; }); const miss = job.weights.filter(w => !files.some(f => serves(f.weight, w))); if (miss.length) warn('shots', `${job.family} (${job.role ?? job.kind}) has no weight ${miss.join(', ')} (got ${weights}): the templates ask for it, so those lines are drawn at the nearest weight. Pick a family with these weights for a heavier headline`); return { cps, files }; } return cps; }); writeFileSync(join(dir, 'fonts.css'), fontsCss(manifest)); writeFileSync(join(dir, 'fonts.json'), JSON.stringify(manifest, null, 1) + '\n'); if (ctx.args.has('json')) return printJson(manifest); for (const s of sizes) log('shots', s); for (const [role, ref] of [['title', sf.title], ['body', sf.body]] as const) { if (!ref.local) continue; const gap = localWeightGap(ref.local.weight, W[role]); if (gap.length) warn('shots', `local font ${ref.local.path} is declared as weight ${ref.local.weight}, but the templates ask the ${role} for ${gap.join(', ')}: those lines are drawn at ${ref.local.weight} (never faked bolder). Add a file with that weight or accept it`); else if (ref.local.weight === undefined && role === 'title') log('shots', `local font ${ref.local.path} has no weight: declared for 100-900, so a static file draws its own weight for the ${role} weights ${W[role].join(', ')}. Give { weight } for a static file`); } for (const f of Object.values(local)) if (!f.license) warn('shots', `local font ${f.path} has no license note: add license ('OFL-1.1', 'proprietary', ...) to its shots.fonts entry`); let auto = 0; const only = ctx.args.list('lang'); const unknown = only.filter(l => !(l in manifest.langs)); if (unknown.length) throw new Error(`--lang: no copy or config language ${unknown.join(', ')} (known: ${Object.keys(manifest.langs).join(', ')})`); for (const [lang, l] of Object.entries(manifest.langs)) for (const f of only.length && !only.includes(lang) ? [] : l.fallbacks) { const ok = manifest.faces[f.face]?.chosen; if (!ok) auto++; const base = f.role === 'title' ? title : body; (ok ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} not in ${base} -> ${f.family}${ok ? ' (shots.fonts.fallback)' : ' (automatic fallback)'}${manifest.faces[f.face]?.local ? ' (local file)' : ''}`); } if (auto) warn('shots', `${auto} automatic fallbacks: renders that use them fail \`shots check\`. Pick title / body fonts that cover them${style === 'game' ? ' (titles: e.g. Paytone One, Baloo 2, Rowdies)' : ''} or accept the fallback with shots.fonts.fallback: ['']`); done('shots', 'fonts done'); }