// Self-hosted UI fonts (research 07 §8): Bender (numerals/HUD) and Novecento // Wide (Latin headers), from TimWangZi/The-font-of-Arknights. Downloaded next // to the site as public/fonts/*.{otf,ttf}, converted to .woff2 (tools/assets/ // woff2.mjs) and exposed through public/fonts/fonts.css. Noto Sans/Serif SC are // NOT self-hosted (Google Fonts with system fallback, see DESIGN §10). import { readFile, writeFile, mkdir, rename, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { RAW, joinUrl } from './sources.mjs'; import { encodeWoff2, decodeWoff2Tables, readSfnt } from './woff2.mjs'; /** Font files: family, weight, source path in the font repo, local base name. */ export const FONTS = Object.freeze([ { family: 'Bender', weight: 400, style: 'normal', src: 'Bender/BENDER.OTF', name: 'bender-regular', ext: 'otf', bytes: 52536 }, { family: 'Bender', weight: 300, style: 'normal', src: 'Bender/BenderLight.woff.ttf', name: 'bender-light', ext: 'ttf', bytes: 68256 }, { family: 'Novecento Wide', weight: 400, style: 'normal', src: 'Novecento-Wide-Normal-2.otf', name: 'novecento-wide-normal', ext: 'otf', bytes: 45356 }, ]); /** * Download jobs for the font files (rel paths are relative to public/fonts). * @returns {{rel:string, urls:string[], kind:string, bytes:number}[]} */ export function fontJobs() { return FONTS.map((f) => ({ rel: `${f.name}.${f.ext}`, urls: [joinUrl(RAW.fonts, f.src)], kind: 'font', bytes: f.bytes })); } async function exists(p) { try { return (await stat(p)).isFile(); } catch { return false; } } /** * Convert downloaded fonts to WOFF2 (skipping up-to-date outputs) and write fonts.css. * @param {string} fontsDir absolute public/fonts directory * @param {(m:string)=>void} log * @returns {Promise<{ files: Record, css: string|null, errors: string[] }>} */ export async function buildFonts(fontsDir, log = console.log) { await mkdir(fontsDir, { recursive: true }); const files = {}; const errors = []; const faces = []; for (const f of FONTS) { const orig = join(fontsDir, `${f.name}.${f.ext}`); const woff2 = join(fontsDir, `${f.name}.woff2`); if (!(await exists(orig))) { errors.push(`missing font ${f.name}.${f.ext}`); continue; } const entry = { family: f.family, weight: f.weight, original: `/fonts/${f.name}.${f.ext}` }; try { const src = await readFile(orig); readSfnt(src); // validate let ok = false; if (await exists(woff2)) { // Up to date when it decodes to exactly the source tables. try { const dec = decodeWoff2Tables(await readFile(woff2)); const s = readSfnt(src); ok = dec.tables.length === s.tables.length && s.tables.every((t) => dec.tables.find((d) => d.tag === t.tag)?.data.equals(t.data)); } catch { ok = false; } } if (!ok) { const out = encodeWoff2(src); const tmp = woff2 + '.tmp'; await writeFile(tmp, out); await rename(tmp, woff2); log(`[fonts] ${f.name}.woff2 ${src.length} → ${out.length} B`); } entry.woff2 = `/fonts/${f.name}.woff2`; } catch (e) { errors.push(`${f.name}: ${e.message}`); } files[f.name] = entry; const srcs = []; if (entry.woff2) srcs.push(`url('${entry.woff2}') format('woff2')`); srcs.push(`url('${entry.original}') format('${f.ext === 'ttf' ? 'truetype' : 'opentype'}')`); faces.push(`@font-face {\n font-family: '${f.family}';\n font-style: ${f.style};\n font-weight: ${f.weight};\n` + ` font-display: swap;\n src: ${srcs.join(',\n ')};\n}`); } let css = null; if (faces.length) { css = `/* Generated by tools/fetch-assets.mjs — do not edit.\n` + ` * Bender (c) Jovanny Lemonad / Oleg Zhuravlev, Gladkikh Ivan — free font.\n` + ` * Novecento Wide (c) Jan Tonellato / Synthview — free font.\n` + ` * Usage: font-family: Bender, Rajdhani, 'Noto Sans SC', sans-serif;\n` + ` * font-family: 'Novecento Wide', 'Saira Condensed', 'Noto Sans SC', sans-serif; */\n\n` + faces.join('\n\n') + '\n'; await writeFile(join(fontsDir, 'fonts.css'), css); } return { files, css, errors }; }