Add pagination blocks, renderCore, layout audit and react-pdf quirk tests

TableGuard, BottomSpacer, FirstPageHeader and PageChrome implement the
verified pagination rules; renderCore returns bytes, a normalised layout
tree, audit issues and a layout fingerprint. auditLayout detects overlaps,
ink overflow, truncation, header-only pages and split blocks. Quirk tests
pin the react-pdf behaviours the design depends on, and a child-process
test pins the page-level absolute Svg hang.
This commit is contained in:
2026-10-04 05:05:51 +05:30
parent 40aa3e05d9
commit 8d4f8e666b
24 changed files with 2187 additions and 5 deletions
+304
View File
@@ -0,0 +1,304 @@
/**
* 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 path from "node:path";
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";
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));
});
});
});