Files
Voiced/src/pdf/fonts/register.ts
T
xavierk 8a6b1ee32a Fix glyph cache poisoning across renders; add CLAUDE.md and handoff.md
fontkit caches glyphs per font for the whole process and pdfkit embeds composite
components with no code points, so setting ':' before '.' in Poppins corrupted every
later render in the same process (text layer showed '1,200;00', line breaks shifted).
primeFontGlyphs() now creates the component glyphs with their cmap code point at font
registration, awaited by renderCore and browser init. Regression test and Quirk 13 added;
the warm-up workarounds in the template tests are removed.
2026-10-04 13:11:40 +05:30

137 lines
5.2 KiB
TypeScript

import { Font } from "@react-pdf/renderer";
import { FONT_MANIFEST } from "./manifest";
import { familySources } from "./stack";
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<number, unknown>;
_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<number>();
// _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<number, number>();
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);
}
let primed: Promise<void> | null = null;
/**
* 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. Memoised; await it before the first render.
*/
export function primeFontGlyphs(): Promise<void> {
if (!registered) return Promise.resolve();
if (!primed) {
primed = run().catch((err) => {
primed = null;
throw err;
});
}
return primed;
async function run(): Promise<void> {
for (const fam of FONT_MANIFEST.families) {
for (const s of familySources(fam)) {
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle };
await Font.load(descriptor);
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
if (data) primeComponents(data);
}
}
}
}
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<Uint8Array>,
base: string,
): Promise<string[]> {
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;
}