storebound/cli/shots/local-fonts.ts

130 lines
8.5 KiB
TypeScript

// A game's or app's own brand font as a local file, next to the Google Fonts families:
// shots.fonts.title / body / fallback[] = 'Paytone One' (Google Fonts family)
// | 'assets/fonts/er-display.woff2' (local file, family from its name table)
// | { family, file, weight?, style?, license? }
// Paths are relative to the config. TTF / OTF / WOFF / WOFF2 (checked by extension and signature). The file is never
// copied: `shots fonts` reads its cmap and records it in fonts.json (path relative to the project, sha256, license),
// fonts.css points at /fonts/local/<slug>.<ext>?v=<hash> and the render / video servers serve that route from the
// original file. A changed file changes fonts.css (capture cache) and fails render until `shots fonts` runs again.
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { basename, join, relative } from 'node:path';
import { fontFamilyName } from './cmap';
import type { FontManifest } from './glyphs';
import type { FontSpec, LocalFontSpec, ShotsConfig } from '../lib/config';
export type { FontSpec, LocalFontSpec };
export type FontFormat = 'truetype' | 'opentype' | 'woff' | 'woff2';
/** A local font as fonts.json records it. path: relative to the project root. */
export interface LocalFontInfo { path: string; format: FontFormat; hash: string; bytes: number; weight?: string; style?: string; license?: string }
/** A resolved font of shots.fonts: the family name the templates use, plus the file for a local font. */
export interface FontRef { family: string; local?: LocalFontInfo & { abs: string } }
const EXT: Record<string, FontFormat> = { ttf: 'truetype', otf: 'opentype', woff: 'woff', woff2: 'woff2' };
/** A string spec is a local file when it ends in a font extension or contains a path separator. */
export const isLocalSpec = (s: FontSpec): boolean => typeof s !== 'string' || /\.(ttf|otf|woff2?)$/i.test(s) || /[\\/]/.test(s);
/** Format from the file signature (null = not a font file storebound reads). */
export function sniffFormat(bin: Uint8Array): FontFormat | null {
if (bin.byteLength < 12) return null;
const sig = String.fromCharCode(bin[0]!, bin[1]!, bin[2]!, bin[3]!);
if (sig === 'wOF2') return 'woff2';
if (sig === 'wOFF') return 'woff';
if (sig === 'OTTO') return 'opentype';
if (sig === 'true' || (bin[0] === 0 && bin[1] === 1 && bin[2] === 0 && bin[3] === 0)) return 'truetype';
return null;
}
export const fileHash = (bin: Uint8Array): string => createHash('sha256').update(bin).digest('hex').slice(0, 16);
/** Resolves and validates one local font spec (file exists, TTF / OTF / WOFF / WOFF2). Throws with the config key. */
export function loadLocalFont(spec: FontSpec, key: string, resolve: (p: string) => string, root: string): FontRef {
const o: LocalFontSpec = typeof spec === 'string' ? { file: spec } : spec;
if (!o.file || typeof o.file !== 'string') throw new Error(`${key}: { family, file } needs a file path (relative to the config)`);
const ext = o.file.split('.').pop()!.toLowerCase();
if (!EXT[ext]) throw new Error(`${key}: ${o.file}: not a font file (TTF, OTF, WOFF or WOFF2)`);
const abs = resolve(o.file);
if (!existsSync(abs) || !statSync(abs).isFile()) throw new Error(`${key}: ${o.file} not found (paths are relative to the config: ${abs})`);
const bin = new Uint8Array(readFileSync(abs));
const format = sniffFormat(bin);
if (!format) throw new Error(`${key}: ${o.file} is not a TTF / OTF / WOFF / WOFF2 font (unknown signature)`);
const sfnt = (f: FontFormat): boolean => f === 'truetype' || f === 'opentype';
if (format !== EXT[ext] && !(sfnt(format) && sfnt(EXT[ext]!))) throw new Error(`${key}: ${o.file} is ${format.toUpperCase()} inside, but named .${ext}: rename it`);
const family = (o.family ?? fontFamilyName(bin) ?? basename(o.file).replace(/\.[^.]+$/, '')).trim();
if (!family) throw new Error(`${key}: ${o.file}: no family name (give { family, file })`);
return { family, local: { abs, path: relative(root, abs), format, hash: fileHash(bin), bytes: bin.byteLength,
...(o.weight !== undefined ? { weight: String(o.weight) } : {}), ...(o.style ? { style: o.style } : {}), ...(o.license ? { license: o.license } : {}) } };
}
/** shots.fonts resolved: Google families as names, local files loaded and validated (throws on a bad file). */
export function resolveShotFonts(shots: Pick<ShotsConfig, 'fonts'> | undefined, style: 'game' | 'app', resolve: (p: string) => string, root: string): { title: FontRef; body: FontRef; fallback: FontRef[]; titleVariable: boolean } {
const d = style === 'app' ? { title: 'Inter', body: 'Inter' } : { title: 'Paytone One', body: 'Montserrat' };
const ref = (s: FontSpec, key: string): FontRef => isLocalSpec(s) ? loadLocalFont(s, key, resolve, root) : { family: s as string };
const fallback = (shots?.fonts?.fallback ?? []).map((f, i) => ref(f, `shots.fonts.fallback[${i}]`));
return { title: ref(shots?.fonts?.title ?? d.title, 'shots.fonts.title'), body: ref(shots?.fonts?.body ?? d.body, 'shots.fonts.body'), fallback, titleVariable: style === 'app' };
}
/** Local files by family for planFonts (one family, one file). */
export function localByFamily(refs: FontRef[]): Record<string, LocalFontInfo> {
const out: Record<string, LocalFontInfo> = {};
for (const r of refs) if (r.local) {
const { abs: _abs, ...info } = r.local;
if (out[r.family] && out[r.family]!.hash !== info.hash) throw new Error(`shots.fonts: two different files for the family "${r.family}" (give each a distinct family)`);
out[r.family] = info;
}
return out;
}
/** Family name of a spec without reading files (Google family, { family }, else the file name). For callers that only
* need a name and never a local file's name table. */
export const specFamily = (s: FontSpec): string => typeof s !== 'string' ? s.family ?? basename(s.file).replace(/\.[^.]+$/, '')
: isLocalSpec(s) ? basename(s).replace(/\.[^.]+$/, '') : s;
export const slug = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
/** fonts.json file name of a local face: served at /fonts/local/<slug>.<ext>. */
export const localFile = (family: string, l: Pick<LocalFontInfo, 'format'>): string =>
`local/${slug(family)}.${l.format === 'truetype' ? 'ttf' : l.format === 'opentype' ? 'otf' : l.format}`;
/** Absolute file of a face: the downloaded subset in .storebound/fonts, or the local font file itself. */
export function faceFilePath(fontsDir: string, root: string, f: FontManifest['faces'][string]): string {
return f.local ? join(root, f.local.path) : join(fontsDir, f.file);
}
/** File for a /fonts/<rel> request of the render / video page (null = not ours). */
export function fontsRoute(rel: string, fontsDir: string, root: string, m: FontManifest | null): string | null {
rel = rel.split('?')[0]!;
if (rel.includes('..')) return null;
if (rel.startsWith('local/')) {
const f = Object.values(m?.faces ?? {}).find(x => x.local && x.file === rel);
return f ? faceFilePath(fontsDir, root, f) : null;
}
return join(fontsDir, rel);
}
export const FONT_TYPES: Record<string, string> = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css' };
/** Local faces whose file changed (or vanished) since `shots fonts`. */
export function staleLocalFonts(m: FontManifest, root: string): string[] {
const out: string[] = [];
for (const f of new Map(Object.values(m.faces).filter(x => x.local).map(x => [x.local!.path, x] as const)).values()) {
const abs = join(root, f.local!.path);
if (!existsSync(abs)) out.push(`${f.family}: ${f.local!.path} is gone`);
else if (fileHash(new Uint8Array(readFileSync(abs))) !== f.local!.hash) out.push(`${f.family}: ${f.local!.path} changed`);
}
return out;
}
/** `shots check` warnings for local fonts without a license note. */
export function localFontIssues(m: FontManifest | null): { level: 'warn'; code: 'font-license'; msg: string }[] {
const seen = new Set<string>();
const out: { level: 'warn'; code: 'font-license'; msg: string }[] = [];
for (const f of Object.values(m?.faces ?? {})) {
if (!f.local || f.local.license || seen.has(f.local.path)) continue;
seen.add(f.local.path);
out.push({ level: 'warn', code: 'font-license', msg: `local font ${f.family} (${f.local.path}) has no license note: add license ('OFL-1.1', 'proprietary', or a short note) to its shots.fonts entry, and make sure you may embed it in store graphics` });
}
return out;
}