import { Font } from "@react-pdf/renderer"; import { FONT_MANIFEST } from "./manifest"; import { familySources } from "./stack"; import { assertSfnt, familyNameFor, isUserFamilyKnown, rememberUserFiles, userFamilySources, type UserFace, type UserFontFile, } from "./userFontStore"; export { FONT_STACK, familySources, stack, type FamilySource } from "./stack"; let registered = false; /** * Registers every bundled family from the manifest. Idempotent; never call Font.reset(), the fonts * would not load again. * * `base` is a URL prefix in the app ("/fonts") and a directory under Node (tests). Only static TTF is * used: WOFF2 embeds glyphs without outlines (invisible text) and variable fonts embed only their * default instance. */ export function registerBundledFonts(base: string): void { if (registered) return; registered = true; for (const fam of FONT_MANIFEST.families) { const sources = familySources(fam).map((s) => ({ src: `${base}/${fam.folder}/${s.file}`, fontWeight: s.fontWeight, fontStyle: s.fontStyle, })); Font.register({ family: fam.family, fonts: sources }); } // The default hyphenation inserts "-" into e-mail addresses, URLs and identifiers. Font.registerHyphenationCallback((word) => [word]); } /** The slice of a fontkit TTF font the priming touches (fontkit's types do not declare it). */ interface FkFont { numGlyphs: number; characterSet: number[]; glyphForCodePoint(cp: number): unknown; _getBaseGlyph(id: number): { _decode?(): { components?: Array<{ glyphID: number }> } | null }; _glyphs: Record; _cmapProcessor: { lookup(cp: number): number }; } const isPrivateUse = (cp: number) => cp >= 0xe000 && cp <= 0xf8ff; /** * Creates, through the cmap and so with their code point, every glyph that a composite glyph uses as a * component. Not the whole cmap: a glyph that layout substitutes in (Inter's contextual hyphen between digits) * is mapped from a private-use code point, and creating it from there would give it a code point the text * never had. Components that only a private-use code point reaches are left alone. */ function primeComponents(font: FkFont): void { const components = new Set(); // _getBaseGlyph caches what it creates, with no code points; scan into a scratch cache so that does not happen. const cache = font._glyphs; font._glyphs = {}; try { for (let id = 0; id < font.numGlyphs; id++) { const decoded = font._getBaseGlyph(id)._decode?.(); for (const c of decoded?.components ?? []) components.add(c.glyphID); } } finally { font._glyphs = cache; } const lowest = new Map(); for (const cp of [...font.characterSet].sort((a, b) => a - b)) { if (isPrivateUse(cp)) continue; const id = font._cmapProcessor.lookup(cp); if (components.has(id) && !lowest.has(id)) lowest.set(id, cp); } for (const cp of lowest.values()) font.glyphForCodePoint(cp); } /** Descriptors already primed; a font registered after the first render (a user font) is primed on the next call. */ const primedKeys = new Set(); let primeChain: Promise = Promise.resolve(); /** * Loads every registered face and gives the component glyphs of its composites their code points. fontkit * caches glyph objects per font for the whole process, and pdfkit creates a composite's components at embed * time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.' * would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00", * textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders * before it. Covers the bundled families and any user family registered so far, each descriptor once, so a * font registered later is not skipped. Await it before the first render. */ export function primeFontGlyphs(): Promise { if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve(); const next = primeChain.then(run); primeChain = next.catch(() => undefined); return next; async function run(): Promise { const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = []; if (registered) { for (const fam of FONT_MANIFEST.families) { for (const s of familySources(fam)) descriptors.push({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle }); } } for (const family of userFamilies) { for (const s of userFamilySources(family)) descriptors.push({ fontFamily: family, fontWeight: s.fontWeight, fontStyle: s.fontStyle }); } for (const descriptor of descriptors) { const key = `${descriptor.fontFamily}|${descriptor.fontWeight}|${descriptor.fontStyle}`; if (primedKeys.has(key)) continue; await Font.load(descriptor); const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data; if (data) primeComponents(data); primedKeys.add(key); } } } // ---- User-imported fonts (Now, Gotham, Open Sauce One) ---- const userFamilies: string[] = []; const isNode = typeof process !== "undefined" && !!process.versions?.node; /** * A src react-pdf can load the bytes from. In a browser or worker that is a blob: URL (react-pdf fetches it; * a data URI would be decoded character by character, which is slow for a multi-MB font). Under Node it is a * temporary file, because react-pdf's Node build opens anything that is not an http(s) URL as a path. */ async function sourceFor(bytes: Uint8Array, id: string): Promise { if (!isNode) return URL.createObjectURL(new Blob([bytes as BlobPart], { type: "font/ttf" })); const nodeFs = "node:fs"; const nodeOs = "node:os"; const nodePath = "node:path"; const [fs, os, path] = await Promise.all([import(/* @vite-ignore */ nodeFs), import(/* @vite-ignore */ nodeOs), import(/* @vite-ignore */ nodePath)]); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "voiced-userfont-")); const file = path.join(dir, `${id.slice(0, 16)}.ttf`); fs.writeFileSync(file, bytes); return file; } /** * Registers one face's files as the family `user::` and returns that name. Every file's magic bytes * are checked before fontkit sees it. Each weight without an italic gets its upright file as the italic. Idempotent: * the same set registers once. Never resets the font store; a different set is a different family. */ export async function registerUserFontSet(face: UserFace, files: UserFontFile[]): Promise { if (files.length === 0) throw new Error(`No font files were given for ${face}`); for (const f of files) assertSfnt(f.bytes); const family = familyNameFor(face, files.map((f) => f.sha256)); if (isUserFamilyKnown(family)) return family; rememberUserFiles(family, files); const srcs = new Map(); for (const s of userFamilySources(family)) { if (!srcs.has(s.id)) srcs.set(s.id, await sourceFor(s.bytes, s.id)); } Font.register({ family, fonts: userFamilySources(family).map((s) => ({ src: srcs.get(s.id) as string, fontWeight: s.fontWeight as never, fontStyle: s.fontStyle })), }); userFamilies.push(family); await primeFontGlyphs(); return family; } /** Cheap non-cryptographic id (two FNV-1a passes, 16 hex digits) for callers that have no sha256, i.e. tests. */ function fallbackId(bytes: Uint8Array): string { let a = 0x811c9dc5; let b = 0x01000193; for (let i = 0; i < bytes.length; i++) { a = Math.imul(a ^ bytes[i], 0x01000193) >>> 0; b = Math.imul(b ^ bytes[i], 0x811c9dc5) >>> 0; } return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0"); } /** One-file convenience for Node tests and single-weight imports: the font as the only member of the face's set. */ export async function registerUserFont( face: UserFace, bytes: Uint8Array, opts: { weight?: number; italic?: boolean; sha256?: string } = {}, ): Promise<{ family: string; sha256: string }> { const sha256 = opts.sha256 ?? fallbackId(bytes); const family = await registerUserFontSet(face, [{ face, weight: opts.weight ?? 400, italic: opts.italic ?? false, sha256, bytes }]); return { family, sha256 }; } const MIN_FONT_BYTES = 10 * 1024; function hasTrueTypeMagic(b: Uint8Array): boolean { if (b.length < 4) return false; if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return true; return String.fromCharCode(b[0], b[1], b[2], b[3]) === "true"; } /** * Fetches every bundled font and returns a list of problems (empty when all are fine). Tauri answers * an unknown path with index.html and HTTP 200, so the response bytes must be checked, not the status. */ export async function preflightFonts( fetchBytes: (url: string) => Promise, base: string, ): Promise { const problems: string[] = []; for (const fam of FONT_MANIFEST.families) { for (const font of fam.fonts) { const url = `${base}/${fam.folder}/${font.file}`; try { const bytes = await fetchBytes(url); if (!hasTrueTypeMagic(bytes)) problems.push(`${url}: not a TrueType font`); else if (bytes.length <= MIN_FONT_BYTES) problems.push(`${url}: only ${bytes.length} bytes`); } catch (err) { problems.push(`${url}: ${err instanceof Error ? err.message : String(err)}`); } } } return problems; }