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.
326 lines
18 KiB
TypeScript
326 lines
18 KiB
TypeScript
/**
|
|
* Pins the react-pdf 4.9.0 behaviour the engine is built on (layout 5.2.0, textkit 7.0.1, fontkit 2.0.4,
|
|
* pdfkit 0.20.1). Each title states the verified fact; when an upgrade changes one of them the test
|
|
* 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";
|
|
import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./engine/ids";
|
|
import type { LayoutPage } from "./engine/layoutTree";
|
|
import { getMeasurer } from "./engine/measure";
|
|
import { AvailableWidthProvider } from "./blocks/AvailableWidth";
|
|
import { BottomSpacer } from "./blocks/BottomSpacer";
|
|
import { planItemsTable, TableRows } from "./blocks/ItemsTable";
|
|
import { TYPE_ROLES } from "./fonts/roles";
|
|
import { RenderTxt } from "./blocks/RenderTxt";
|
|
import { TableGuard, guardPresence } from "./blocks/TableGuard";
|
|
import { renderCore } from "./render/core";
|
|
import { loadCanvas, inkPixels } from "./testing/rasterize";
|
|
import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs";
|
|
import { FONTS_DIR, setupPdfTest } from "./testing/setup";
|
|
import { stack } from "./fonts/register";
|
|
|
|
setupPdfTest();
|
|
|
|
const render = (el: ReactElement) => renderCore(el);
|
|
|
|
const LONG =
|
|
"Aurora Industrial Fasteners and Precision Components Private Limited, Plot 14, Phase 2, Peenya Industrial Area, Bengaluru, Karnataka";
|
|
|
|
describe("react-pdf 4.9.0 quirks the engine relies on", () => {
|
|
it("1. flexGrow without flexBasis 0 leaves stale line breaks: the text is wider than its final box; with flexBasis 0 it is not", async () => {
|
|
const row = (fill: Record<string, unknown>) =>
|
|
doc(
|
|
h(
|
|
View,
|
|
{ style: { flexDirection: "row", width: 360 } },
|
|
h(Text, { id: "fill", style: txt({ ...fill }) }, LONG),
|
|
h(View, { style: { width: 180, minWidth: 180, maxWidth: 180 } }, h(Text, { style: txt() }, "side")),
|
|
),
|
|
);
|
|
const widest = async (fill: Record<string, unknown>) => {
|
|
const r = await render(row(fill));
|
|
const t = byId(r.layout[0], "fill")[0];
|
|
return { box: t.box.w, ink: Math.max(...t.lines!.map((l) => l.w)) };
|
|
};
|
|
const stale = await widest({ flexGrow: 1 });
|
|
expect(stale.ink).toBeGreaterThan(stale.box + 0.5);
|
|
const fixed = await widest({ flexGrow: 1, flexBasis: 0 });
|
|
expect(fixed.ink).toBeLessThanOrEqual(fixed.box + 0.5);
|
|
});
|
|
|
|
it("2. a static Text with lineHeight '13.8pt' ends with style.lineHeight 13800 (after the relayout a dynamic page forces) while its line boxes are 13.8 tall", async () => {
|
|
const dynamic = h(RenderTxt, { key: "d", role: { face: "IBM Plex Sans", weight: 400, size: 8 }, fixed: true, render: ({ pageNumber }) => `p${pageNumber}` });
|
|
const statik = () => h(Text, { key: "t", id: "t", style: txt({ lineHeight: "13.8pt" }) }, "Hello");
|
|
const raw = await rawLayout(doc([statik(), dynamic]));
|
|
const text = raw.children[0].children[0];
|
|
expect(text.type).toBe("TEXT");
|
|
expect(text.style.lineHeight).toBe(13800);
|
|
expect(text.lines[0].box.height).toBeCloseTo(13.8, 5);
|
|
const r = await render(doc([statik(), dynamic]));
|
|
expect(byId(r.layout[0], "t")[0].lines![0].h).toBeCloseTo(13.8, 5);
|
|
|
|
// Without a dynamic node the page is laid out once and the unit is still wrong (13.8pt -> 138).
|
|
const single = await rawLayout(doc(statik()));
|
|
expect(single.children[0].children[0].style.lineHeight).toBe(138);
|
|
});
|
|
|
|
describe("3. fixed, wrap and minPresenceAhead are checked by key presence", () => {
|
|
// Four 60 pt rows fill 20..260 of the 20..280 wrap area; the fifth straddles the page foot.
|
|
const rows = (fifth: Record<string, unknown>) =>
|
|
doc(
|
|
[0, 1, 2, 3, 4].map((i) =>
|
|
h(View, { key: i, id: `r${i}`, style: { height: 60 }, ...(i === 4 ? fifth : { wrap: false }) }, h(Text, { style: txt() }, `Row ${i}`)),
|
|
),
|
|
);
|
|
const place = async (fifth: Record<string, unknown>) => {
|
|
const r = await render(rows(fifth));
|
|
return r.layout.map((p) => byId(p, "r4").map((n) => ({ y: n.box.y, h: n.box.h })));
|
|
};
|
|
|
|
it("wrap={false} without `fixed` moves the whole row to the next page", async () => {
|
|
const where = await place({ wrap: false });
|
|
expect(where[0]).toEqual([]);
|
|
expect(where[1]).toHaveLength(1);
|
|
expect(where[1][0].h).toBe(60);
|
|
});
|
|
|
|
it("wrap={false} with fixed={false} splits the row instead (fixed is key presence)", async () => {
|
|
const where = await place({ wrap: false, fixed: false });
|
|
expect(where[0].length).toBeGreaterThan(0);
|
|
expect(where[0][0].h).toBeLessThan(60);
|
|
});
|
|
|
|
it("wrap={undefined} cannot wrap, as if wrap={false}; omitting `wrap` splits a straddling row", async () => {
|
|
const omitted = await place({});
|
|
expect(omitted[0][0].h).toBeLessThan(60);
|
|
const undef = await place({ wrap: undefined });
|
|
expect(undef[0]).toEqual([]);
|
|
});
|
|
|
|
it("minPresenceAhead={undefined} never triggers; a number moves the sentinel (and what follows) to the next page", async () => {
|
|
const sentinel = (extra: Record<string, unknown>) =>
|
|
doc([
|
|
h(View, { key: "fill", id: "fill", style: { height: 200 } }),
|
|
h(View, { key: "s", id: "s", style: { height: 0 }, ...extra }),
|
|
h(View, { key: "t", id: "t", style: { height: 100 }, wrap: false }, h(Text, { style: txt() }, "tail")),
|
|
]);
|
|
const pageOf = async (extra: Record<string, unknown>) => {
|
|
const r = await render(sentinel(extra));
|
|
return r.layout.findIndex((p) => byId(p, "s").length > 0) + 1;
|
|
};
|
|
expect(await pageOf({ minPresenceAhead: 100 })).toBe(2);
|
|
expect(await pageOf({ minPresenceAhead: undefined })).toBe(1);
|
|
expect(await pageOf({})).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("4. a family registered upright-only throws 'Could not resolve font ... italic' in a fallback stack; the aliased registration does not", async () => {
|
|
const file = path.join(FONTS_DIR, "ibm-plex-sans/IBMPlexSans-Regular.ttf");
|
|
Font.register({ family: "Quirk Upright Only", fonts: [{ src: file, fontWeight: 400, fontStyle: "normal" }] });
|
|
Font.register({
|
|
family: "Quirk Aliased",
|
|
fonts: [
|
|
{ src: file, fontWeight: 400, fontStyle: "normal" },
|
|
{ src: file, fontWeight: 400, fontStyle: "italic" },
|
|
],
|
|
});
|
|
const italic = (family: string[]) => doc(h(Text, { style: { fontFamily: family, fontStyle: "italic", fontSize: 10 } }, "Italic text"));
|
|
await expect(render(italic(["IBM Plex Sans", "Quirk Upright Only"]))).rejects.toThrow(/Could not resolve font.*italic/i);
|
|
await expect(render(italic(["IBM Plex Sans", "Quirk Aliased"]))).resolves.toBeTruthy();
|
|
// The bundled stack (Noto faces have no real italic) is aliased by register.ts.
|
|
await expect(render(italic(FAMILY))).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("5. a WOFF2 font embeds glyphs with no outlines: the text area stays white, a TTF control has ink", async () => {
|
|
const canvasLib = await loadCanvas();
|
|
if (!canvasLib) {
|
|
console.warn("quirk 5 skipped: @napi-rs/canvas (optional dependency) is not installed");
|
|
return;
|
|
}
|
|
Font.register({ family: "Quirk Woff2", src: path.join(FONTS_DIR, "IBMPlexSans-Regular.woff2") });
|
|
Font.register({ family: "Quirk Ttf", src: path.join(FONTS_DIR, "ibm-plex-sans/IBMPlexSans-Regular.ttf") });
|
|
const sample = (family: string) =>
|
|
renderToBuffer(
|
|
doc(h(Text, { style: { fontFamily: family, fontSize: 40, color: "#000000" } }, "HWMN"), { width: 300, height: 100 }) as never,
|
|
);
|
|
const area = { x0: 10, y0: 10, x1: 290, y1: 90 };
|
|
const woff = await inkPixels(canvasLib, new Uint8Array(await sample("Quirk Woff2")), area);
|
|
const ttf = await inkPixels(canvasLib, new Uint8Array(await sample("Quirk Ttf")), area);
|
|
expect(ttf).toBeGreaterThan(500);
|
|
expect(woff).toBe(0);
|
|
});
|
|
|
|
it("6. a newline or tab inside a Text pulls an unembedded Helvetica into the PDF", async () => {
|
|
const baseFonts = async (s: string) =>
|
|
(await renderToBuffer(doc(h(Text, { style: txt() }, s)) as never)).toString("latin1").match(/\/BaseFont \/[A-Za-z+]*Helvetica[A-Za-z-]*/g) ?? [];
|
|
expect(await baseFonts("plain text")).toEqual([]);
|
|
expect((await baseFonts("two\nlines")).length).toBeGreaterThan(0);
|
|
expect((await baseFonts("a\tb")).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("7. a pinned height on a text container truncates with an ellipsis (lines lost); minHeight does not", async () => {
|
|
const name = "Aurora Industrial Fasteners and Precision Components Private Limited of Peenya";
|
|
const box = (sizing: Record<string, unknown>) =>
|
|
doc(h(View, { style: { width: 100, ...sizing } }, h(Text, { id: "name", style: txt() }, name)));
|
|
const pinned = (await render(box({ height: 33 }))).layout[0];
|
|
const t = byId(pinned, "name")[0];
|
|
expect(t.lines!.length).toBe(2);
|
|
expect(t.lines!.map((l) => l.text).join(" ").endsWith("…")).toBe(true);
|
|
const frame = frameFor();
|
|
expect(auditLayout({ pages: [pinned] }, frame).map((i) => i.code)).toContain("text-truncated");
|
|
|
|
const free = (await render(box({ minHeight: 33 }))).layout[0];
|
|
const f = byId(free, "name")[0];
|
|
expect(f.lines!.length).toBeGreaterThan(3);
|
|
// Line strings keep their trailing space, so they concatenate back to the source.
|
|
expect(f.lines!.map((l) => l.text).join("")).toBe(name);
|
|
expect(auditLayout({ pages: [free] }, frame).map((i) => i.code)).not.toContain("text-truncated");
|
|
});
|
|
|
|
describe("8. table header and TableGuard", () => {
|
|
const WIDTH = 360;
|
|
const columns = [{ fill: true as const }, { fixed: 60 }];
|
|
const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody };
|
|
const body = Array.from({ length: 80 }, (_, i) => [`Item ${i + 1}`, `${i + 1}`]);
|
|
const plan = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 });
|
|
const frame = frameFor();
|
|
const table = () =>
|
|
h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, roles, padY: 4, border: 0.5 });
|
|
const withFiller = (fillerH: number, guarded: boolean) =>
|
|
doc(
|
|
h(
|
|
AvailableWidthProvider,
|
|
{ width: WIDTH },
|
|
h(View, { id: "filler", style: { height: fillerH } }),
|
|
guarded ? h(TableGuard, { minPresenceAhead: guardPresence(plan.headH, plan.firstRowH, TYPE_ROLES.tableBody.leading) }) : null,
|
|
table(),
|
|
),
|
|
);
|
|
const pageStats = (l: LayoutPage) => ({
|
|
head: find(l, (n) => n.id === TABLE_HEAD_ID).length,
|
|
rows: find(l, (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX)).length,
|
|
});
|
|
|
|
it("a `fixed` first child repeats the header on every page of an 80-row table", async () => {
|
|
const r = await render(withFiller(0, true));
|
|
expect(r.pages).toBeGreaterThan(2);
|
|
r.layout.forEach((p) => expect(pageStats(p).head).toBe(1));
|
|
expect(r.layout.reduce((n, p) => n + pageStats(p).rows, 0)).toBe(80);
|
|
});
|
|
|
|
it("without a guard, a header alone can sit at the page foot (header-only-page); TableGuard prevents it", async () => {
|
|
// Leave room for the header and a few pt: less than header + first row.
|
|
const filler = frame.contentHeight - plan.headH - 4;
|
|
const bad = await render(withFiller(filler, false));
|
|
expect(pageStats(bad.layout[0])).toEqual({ head: 1, rows: 0 });
|
|
expect(auditLayout({ pages: bad.layout }, frame).map((i) => i.code)).toContain("header-only-page");
|
|
|
|
const good = await render(withFiller(filler, true));
|
|
// The guard moves the whole table: page 1 keeps only the filler.
|
|
expect(pageStats(good.layout[0])).toEqual({ head: 0, rows: 0 });
|
|
good.layout.slice(1).forEach((p) => expect(pageStats(p).rows).toBeGreaterThan(0));
|
|
expect(auditLayout({ pages: good.layout }, frame).map((i) => i.code)).not.toContain("header-only-page");
|
|
});
|
|
});
|
|
|
|
describe("9. BottomSpacer", () => {
|
|
// 60 rows of 16 pt in a 490 pt wrap area: 30 per page, so pages 1 and 2 are full (10 pt spare, less
|
|
// than the closing block) and the closing block must start page 3.
|
|
const CLOSING_H = 80;
|
|
const build = (spacer: ReactNode) =>
|
|
doc(
|
|
[
|
|
...Array.from({ length: 60 }, (_, i) => h(View, { key: i, style: { height: 16 } }, h(Text, { style: txt({ fontSize: 8, lineHeight: "10pt" }) }, `Row ${i + 1}`))),
|
|
spacer,
|
|
h(View, { key: "closing", id: "closing", style: { height: CLOSING_H }, wrap: false }, h(Text, { style: txt() }, "Amount in words")),
|
|
],
|
|
{ height: 530 },
|
|
);
|
|
const closing = async (spacer: ReactNode) => {
|
|
const r = await render(build(spacer));
|
|
const page = r.layout.findIndex((p) => byId(p, "closing").length > 0);
|
|
return { pages: r.pages, page: page + 1, node: byId(r.layout[page], "closing")[0] };
|
|
};
|
|
|
|
it("with minPresenceAhead = closing height the closing block sits at the foot of page 3; without it, at the top", async () => {
|
|
const wrapBottom = 530 - PAD;
|
|
const withGuard = await closing(h(BottomSpacer, { key: "sp", closingH: CLOSING_H }));
|
|
expect(withGuard.page).toBe(3);
|
|
expect(withGuard.pages).toBe(3);
|
|
expect(withGuard.node.box.y + withGuard.node.box.h).toBeCloseTo(wrapBottom, 0);
|
|
|
|
const bare = await closing(h(View, { key: "sp", style: { flexGrow: 1, flexBasis: 0 } }));
|
|
expect(bare.page).toBe(3);
|
|
expect(bare.node.box.y).toBeCloseTo(PAD, 0);
|
|
});
|
|
});
|
|
|
|
it("10. fixed chrome placed AFTER the flow repeats on every page (legacy paginator)", async () => {
|
|
const rows = Array.from({ length: 40 }, (_, i) => h(View, { key: i, style: { height: 20 } }, h(Text, { style: txt() }, `Row ${i}`)));
|
|
const footer = h(View, { key: "footer", id: "footer", fixed: true, style: { position: "absolute", bottom: 4, left: PAD } }, h(Text, { style: txt() }, "Footer"));
|
|
const r = await render(doc([...rows, footer]));
|
|
expect(r.pages).toBeGreaterThan(2);
|
|
r.layout.forEach((p) => expect(byId(p, "footer")).toHaveLength(1));
|
|
});
|
|
|
|
it("11. letterSpacing is added after the last glyph too (the Tracked assumption)", async () => {
|
|
const m = getMeasurer();
|
|
const base = m.measureWidth({ text: "AB", face: "IBM Plex Sans", weight: 400, size: 10 });
|
|
const r = await render(doc(h(Text, { id: "t", style: txt({ letterSpacing: 5, textAlign: "right" }) }, "AB")));
|
|
const t = byId(r.layout[0], "t")[0];
|
|
const line = t.lines![0];
|
|
expect(line.w).toBeCloseTo(base + 2 * 5, 2);
|
|
// The advance reaches the box edge, so the last glyph's ink ends one step short of it.
|
|
expect(line.x + line.w).toBeCloseTo(t.box.x + t.box.w, 2);
|
|
});
|
|
|
|
describe("12. render-prop text height", () => {
|
|
const label = (pageNumber: number, totalPages: number) => `Page ${pageNumber} of ${totalPages}`;
|
|
const flow = Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } }, h(Text, { style: txt() }, `Row ${i}`)));
|
|
const chrome = (viewStyle: Record<string, unknown>) =>
|
|
h(
|
|
View,
|
|
{ key: "chrome", id: "chrome", fixed: true, style: { position: "absolute", bottom: 4, left: PAD, width: 200, ...viewStyle } },
|
|
h(RenderTxt, { id: "label", role: { face: "IBM Plex Sans", weight: 400, size: 8 }, render: ({ pageNumber, totalPages }) => label(pageNumber, totalPages) }),
|
|
);
|
|
|
|
it("inside an auto-height fixed View it draws 'Page k of N' on every page", async () => {
|
|
const r = await render(doc([...flow, chrome({})]));
|
|
expect(r.pages).toBeGreaterThan(1);
|
|
r.layout.forEach((p, i) => {
|
|
const t = byId(p, "label")[0];
|
|
expect(t.lines!.map((l) => l.text)).toEqual([label(i + 1, r.pages)]);
|
|
});
|
|
});
|
|
|
|
it("inside a 10 pt fixed-height View at 8 pt Plex (10.4 pt line) it draws 0 lines", async () => {
|
|
const r = await render(doc([...flow, chrome({ height: 10 })]));
|
|
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 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([0x2e]);
|
|
// Text-layer extraction and every other face: src/pdf/fonts/glyphPriming.test.tsx.
|
|
});
|
|
});
|