// Shared helpers for the font manifest builder and verifier (Node only). import { createHash } from "node:crypto"; import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import * as fontkit from "fontkit"; export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const FONTS_DIR = path.join(ROOT, "public/fonts"); export const MANIFEST_PATH = path.join(FONTS_DIR, "manifest.json"); export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); /** Folders under public/fonts that hold bundled families (everything that is a directory). */ export function familyFolders() { return readdirSync(FONTS_DIR) .filter((name) => statSync(path.join(FONTS_DIR, name)).isDirectory()) .sort(); } export function ttfFiles(folder) { return readdirSync(path.join(FONTS_DIR, folder)) .filter((name) => /\.ttf$/i.test(name)) .sort(); } /** First four bytes as a tag: "\0\1\0\0" is reported as "0x00010000". */ export function sfntTag(bytes) { const b = bytes.subarray(0, 4); if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "0x00010000"; return String.fromCharCode(...b); } /** Raw OS/2 fsType (fontkit only exposes decoded flags). */ function rawFsType(bytes) { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const numTables = dv.getUint16(4); for (let i = 0; i < numTables; i++) { const rec = 12 + i * 16; const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]); if (tag === "OS/2") return dv.getUint16(dv.getUint32(rec + 8) + 8); } return null; } /** Reads a TTF with fontkit and returns the facts the manifest records. */ export function inspectFont(folder, file) { const bytes = new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file))); const font = fontkit.create(Buffer.from(bytes)); if (font.fonts) throw new Error(`${folder}/${file}: font collections are not supported`); const os2 = font["OS/2"]; return { bytes, tag: sfntTag(bytes), variable: Boolean(font.directory?.tables?.fvar), // Name ID 16 (typographic family) groups SemiBold etc. under the base family. familyName: font.name?.records?.preferredFamily?.en ?? font.familyName, info: { file, weight: os2.usWeightClass, style: os2.fsSelection.italic || os2.fsSelection.oblique ? "italic" : "normal", sha256: sha256(bytes), fsType: rawFsType(bytes), upm: font.unitsPerEm, ascent: font.ascent, descent: font.descent, lineGap: font.lineGap, capHeight: font.capHeight, xHeight: font.xHeight, hasRupee: font.hasGlyphForCodePoint(0x20b9), hasTabularNums: font.availableFeatures.includes("tnum"), }, }; } export const WEIGHT_WORDS = { Thin: 100, ExtraLight: 200, Light: 300, Regular: 400, Italic: 400, Text: 450, Medium: 500, SemiBold: 600, Bold: 700, ExtraBold: 800, Black: 900, };