118 lines
8 KiB
TypeScript
118 lines
8 KiB
TypeScript
// 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 [--json] (re-run after editing the copy or the fonts)
|
|
// Out: <project>/.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<ArrayBuffer> {
|
|
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<string, RoleTexts> {
|
|
const out: Record<string, RoleTexts> = {};
|
|
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<void> {
|
|
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<FaceResult> => {
|
|
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<number>();
|
|
}
|
|
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;
|
|
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of 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: ['<family>']`);
|
|
done('shots', 'fonts done');
|
|
}
|