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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
Reference in New Issue
Block a user