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.
This commit is contained in:
2026-10-04 13:11:40 +05:30
parent 76533ad2c7
commit 63854dd960
9 changed files with 308 additions and 25 deletions
+69
View File
@@ -0,0 +1,69 @@
/**
* Rendering must not depend on earlier renders in the same process (src/pdf/quirks.test.tsx, quirk 13).
* This file runs in a fresh module context, so the first document below really is the first one that
* touches Poppins Regular.
*/
import { createElement as h } from "react";
import { Font, Text } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { doc } from "../testing/docs";
import { parsePdfText } from "../testing/pdfText";
import { renderCore } from "../render/core";
import { setupPdfTest } from "../testing/setup";
import { FONT_MANIFEST } from "./manifest";
import { familySources, stack } from "./register";
setupPdfTest();
type Glyph = { id: number; codePoints: number[]; components?: Array<{ glyphID: number }> };
type FkFont = { characterSet: number[]; glyphForCodePoint(cp: number): Glyph; getGlyph(id: number): Glyph; numGlyphs: number };
const fontOf = (family: string, fontWeight: number, fontStyle: string) =>
(Font as unknown as { getFont: (d: object) => { data: FkFont } }).getFont({ fontFamily: family, fontWeight, fontStyle }).data;
const poppins = { fontFamily: stack("Poppins"), fontSize: 10, lineHeight: "12pt", fontWeight: 400 };
describe("glyph priming", () => {
it("a colon set in Poppins Regular before any period does not stop a later amount being extracted as text", async () => {
await renderCore(doc(h(Text, { style: poppins }, "Bank : State")));
const r = await renderCore(doc(h(Text, { style: poppins }, "₹1,200.00")));
const pages = await parsePdfText(r.bytes);
const text = pages.pages.flatMap((p) => p.items.map((i) => i.str)).join("");
expect(text).toContain("₹1,200.00");
});
it("the period glyph keeps its code point", () => {
expect(fontOf("Poppins", 400, "normal").glyphForCodePoint(0x2e).codePoints).toEqual([0x2e]);
});
it("every glyph that a composite uses as a component is created with a code point, in every bundled face", async () => {
// Reads fontkit's glyph cache and cmap directly: asking for a glyph through the public API would create it with code points and hide the bug.
type Peek = {
numGlyphs: number;
_glyphs: Record<number, Glyph | undefined>;
_cmapProcessor: { lookup(cp: number): number };
_getBaseGlyph(id: number): { _decode?(): { components?: Array<{ glyphID: number }> } | null };
};
const bad: string[] = [];
let checked = 0;
for (const fam of FONT_MANIFEST.families) {
for (const s of familySources(fam)) {
await Font.load({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
const font = fontOf(fam.family, s.fontWeight, s.fontStyle);
const peek = font as unknown as Peek;
const mapped = new Set(font.characterSet.filter((cp) => cp < 0xe000 || cp > 0xf8ff).map((cp) => peek._cmapProcessor.lookup(cp)));
const comps = new Set<number>();
const cache = peek._glyphs;
peek._glyphs = {};
for (let id = 0; id < peek.numGlyphs; id++) for (const c of peek._getBaseGlyph(id)._decode?.()?.components ?? []) comps.add(c.glyphID);
peek._glyphs = cache;
for (const id of comps) {
if (!mapped.has(id)) continue;
checked++;
if ((peek._glyphs[id]?.codePoints.length ?? 0) === 0) bad.push(`${fam.family} ${s.file} component glyph ${id}`);
}
}
}
expect(checked).toBeGreaterThan(1000);
expect(bad).toEqual([]);
});
});
+72
View File
@@ -31,6 +31,78 @@ export function registerBundledFonts(base: string): void {
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 {
+16 -4
View File
@@ -4,7 +4,9 @@
* fails and the design rule behind it must be revisited.
*/
import { createElement as h, type ReactElement, type ReactNode } from "react";
import { readFileSync } from "node:fs";
import path from "node:path";
import { create as createFont } from "fontkit";
import { Font, renderToBuffer, Text, View } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { auditLayout } from "./engine/audit";
@@ -302,12 +304,22 @@ describe("react-pdf 4.9.0 quirks the engine relies on", () => {
r.layout.forEach((p) => expect(byId(p, "label")[0].lines).toHaveLength(0));
});
});
it("13. fontkit caches glyph objects per font for the whole process, and pdfkit creates a composite's component glyphs at embed time with no code points: Poppins' colon makes the period glyph keep codePoints [] (so a later document's '.' is not extracted and textkit's glyph-to-string mapping slips)", async () => {
// Must stay the first test that sets Poppins Regular in this file: any earlier '.' in that face would create the period glyph normally.
it("13. fontkit caches glyph objects per font for the whole process, and a subset encode (pdfkit's embed) creates a composite's component glyphs with no code points: Poppins' colon makes the period glyph keep codePoints [] unless the glyphs were created first, which primeFontGlyphs does for every registered face", async () => {
// The mechanism, on a font object of our own so no earlier render in this file matters.
const fresh = createFont(new Uint8Array(readFileSync(path.join(FONTS_DIR, "poppins/Poppins-Regular.ttf")))) as unknown as {
glyphForCodePoint(cp: number): { codePoints: number[] };
createSubset(): { includeGlyph(g: unknown): void; encode(): unknown };
};
const subset = fresh.createSubset();
subset.includeGlyph(fresh.glyphForCodePoint(0x3a));
subset.encode();
expect(fresh.glyphForCodePoint(0x2e).codePoints).toEqual([]);
// The fix: rendering primes the registered faces first, so the colon in the first document leaves the period intact for the next one.
const poppins = { fontFamily: stack("Poppins"), fontSize: 10, lineHeight: "12pt", fontWeight: 400 };
await render(doc(h(Text, { style: poppins }, "Bank : State")));
const data = (Font as unknown as { getFont: (d: object) => { data: { glyphForCodePoint: (cp: number) => { codePoints: number[] } } } }).getFont({ fontFamily: "Poppins", fontWeight: 400, fontStyle: "normal" }).data;
expect(data.glyphForCodePoint(0x2e).codePoints).toEqual([]);
// The Highlighter and Marble tests and the standalone sweep warm Poppins with a document that has amounts (period and comma) first, for this reason.
expect(data.glyphForCodePoint(0x2e).codePoints).toEqual([0x2e]);
// Text-layer extraction and every other face: src/pdf/fonts/glyphPriming.test.tsx.
});
});
+2 -1
View File
@@ -1,6 +1,6 @@
import { createMeasurer, setMeasurer } from "../engine/measure";
import { FONT_MANIFEST } from "../fonts/manifest";
import { preflightFonts, registerBundledFonts } from "../fonts/register";
import { preflightFonts, primeFontGlyphs, registerBundledFonts } from "../fonts/register";
export interface BrowserInitOptions {
/** URL prefix of the bundled fonts. Default "/fonts". */
@@ -48,6 +48,7 @@ async function run({ base = "/fonts", fetchBytes = defaultFetchBytes }: BrowserI
}
registerBundledFonts(base);
await primeFontGlyphs();
const known = new Set(FONT_MANIFEST.families.flatMap((f) => f.fonts.map((font) => `${f.folder}/${font.file}`)));
setMeasurer(
createMeasurer((file) => {
+2
View File
@@ -3,6 +3,7 @@ import { cloneElement, type ReactElement } from "react";
import { auditLayout, type AuditIssue, type AuditOptions } from "../engine/audit";
import { layoutFingerprint } from "../engine/fingerprint";
import type { PageFrame } from "../engine/geometry";
import { primeFontGlyphs } from "../fonts/register";
import { normalizeLayout, type LayoutPage, type Rect } from "../engine/layoutTree";
export { normalizeLayout, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree";
@@ -66,6 +67,7 @@ export async function renderCore(
},
});
await primeFontGlyphs();
const bytes = await bytesOf(wrapped);
if (raw === undefined) {
throw new Error("renderCore: onRender never delivered a layout tree; the root element must be a <Document> or forward onRender to one");
@@ -1,17 +1,8 @@
import { beforeAll, expect, it } from "vitest";
import { prefsFor, renderFixture } from "./harness";
import { expect, it } from "vitest";
import { describeSweep, STANDALONE_SWEEP } from "./harness.sweep";
import { TEMPLATES } from "./registry";
// Monolith, Cobalt Stripe, Marble and Highlighter: the same sweep as the other templates, in a second file so the two run side by side.
//
// Poppins Regular is warmed with a document that has amounts first. fontkit caches glyph objects per font for the process and
// pdfkit creates a composite's components (the period inside Poppins' colon) at embed time with no code points: Monolith, which
// sets ':' in Poppins Regular but amounts in Medium, would leave the period unextractable for Highlighter's amounts
// (src/pdf/quirks.test.tsx, quirk 13).
beforeAll(async () => {
await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" }));
});
describeSweep((id) => STANDALONE_SWEEP.includes(id));
@@ -1,6 +1,6 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { SLOT_ID } from "../../blocks/slots";
@@ -21,15 +21,6 @@ import { BLACK, HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INDIGO, INK, YELLOW } fr
ensureSetup();
// fontkit caches glyph objects per font for the process, and pdfkit creates a composite's components (the period and comma inside
// Poppins' colon and semicolon) at embed time with no code points (src/pdf/quirks.test.tsx, quirk 13). A first render without any
// period or comma, such as the 3-row sample at 100.00 an hour, then makes a later wrapped client name overflow its box by 3.6 pt
// (textkit's glyph-to-string mapping slips). The tests run in a fixed order, so warm Poppins with the harness's default invoice,
// whose amounts have both.
beforeAll(async () => {
await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" }));
});
const V = HIGHLIGHTER_VARIANT;
const slot = OPTIN_SLOTS.highlighter!;
type R = Awaited<ReturnType<typeof renderFixture>>;