130 lines
8.5 KiB
TypeScript
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;
|
|
}
|