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
+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.
});
});