// 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/.?v= 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 = { 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 | 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 { const out: Record = {}; 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/.. */ export const localFile = (family: string, l: Pick): 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/ 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 = { 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(); 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; }