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
+55
View File
@@ -0,0 +1,55 @@
// Child-process scenarios for src/pdf/hang.test.ts. Bundled with esbuild and run with node, because
// react-pdf's paginate() is a synchronous loop: an in-process timer never fires while it spins.
import { createElement as h } from "react";
import { Document, Page, Path, Svg, Text, View } from "@react-pdf/renderer";
import path from "node:path";
import { FirstPageHeader } from "../../src/pdf/blocks/FirstPageHeader";
import { find } from "../../src/pdf/testing/docs";
import { registerBundledFonts, stack } from "../../src/pdf/fonts/register";
import { renderCore } from "../../src/pdf/render/core";
registerBundledFonts(path.resolve(process.cwd(), "public/fonts"));
const [scenario, rowsArg] = process.argv.slice(2);
const rows = Number(rowsArg);
const W = 595.28;
const H = 841.89;
const PAD = 40;
const text = { fontFamily: stack("IBM Plex Sans"), fontSize: 10, lineHeight: "12pt" };
const flow = Array.from({ length: rows }, (_, i) =>
h(View, { key: i, style: { height: 18 } }, h(Text, { style: text }, `Row ${i + 1}`)),
);
const svg = (extra: Record<string, unknown>) =>
h(
Svg,
{ viewBox: "0 0 100 40", style: { width: W, height: 40, ...extra } },
h(Path, { d: "M0 0 L100 0 L100 40 L0 40 Z", fill: "#cccccc" }),
);
function pageWith(children: unknown[]) {
return h(Document, null, h(Page, { size: { width: W, height: H }, style: { padding: PAD } }, ...(children as never[])));
}
const element =
scenario === "page-level-svg"
? // Non-fixed, absolute, full width, straddling the bottom margin line (H - PAD = 801.89).
pageWith([...flow, svg({ position: "absolute", left: 0, top: H - PAD - 20 })])
: pageWith([
h(
FirstPageHeader,
{
key: "head",
pad: { top: PAD, right: PAD, bottom: PAD, left: PAD },
decor: [{ x: 0, y: 10, width: W, height: 40, children: svg({}) }],
style: { height: 60 },
},
h(Text, { style: text }, "Header"),
),
...flow,
]);
const r = await renderCore(element);
const svgPages = r.layout.map((p) => find(p, (n) => n.type === "svg").length);
console.log(JSON.stringify({ pages: r.pages, svgPages }));
+13
View File
@@ -0,0 +1,13 @@
import { View } from "@react-pdf/renderer";
import { FILL } from "../engine/columns";
import { pdfFlags } from "./pdfProps";
/**
* Elastic gap before the closing block (amount in words, bank, signatory). It grows to push the closing
* block to the foot of whichever page it ends on. `closingH` is the closing block's height: as
* minPresenceAhead it keeps the spacer from staying on the previous page while the block starts the
* next one (without it the spacer stays behind and the block sits at the top of the new page).
*/
export function BottomSpacer({ closingH }: { closingH: number }) {
return <View style={FILL} {...pdfFlags({ minPresenceAhead: closingH })} />;
}
+52
View File
@@ -0,0 +1,52 @@
import { View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { DECOR_ID } from "../engine/ids";
import type { Sides } from "../engine/geometry";
import { pdfFlags } from "./pdfProps";
/** A decorative box placed in PAGE space (pt from the page's top-left corner). */
export interface DecorItem {
x: number;
y: number;
width: number;
height: number;
children?: ReactNode;
}
export interface FirstPageHeaderProps {
/** The page padding from the frame: a header that is first in the flow starts at (pad.left, pad.top). */
pad: Sides;
/** Page-space position of the header's top-left corner; defaults to (pad.left, pad.top). */
pageOrigin?: { x: number; y: number };
/** First-page-only decor, in page space. Converted here to offsets from the header's own corner. */
decor?: DecorItem[];
style?: Record<string, unknown>;
children?: ReactNode;
}
/**
* First thing in the flow, `wrap={false}`. First-page-only decor (Svg, Image, bands) lives here as
* absolute children instead of at page level: a non-fixed absolute Svg at page level that straddles the
* bottom margin makes react-pdf's paginate() loop forever, or drags all later content to the next page.
* Inside this unbreakable block it is laid out once with the header, on page 1.
*
* Absolute offsets are measured from the header's own corner; the header's padding does not shift them.
*/
export function FirstPageHeader({ pad, pageOrigin, decor = [], style, children }: FirstPageHeaderProps) {
const ox = pageOrigin?.x ?? pad.left;
const oy = pageOrigin?.y ?? pad.top;
return (
<View style={style as never} {...pdfFlags({ wrap: false })}>
{decor.map((d, i) => (
<View
key={i}
id={DECOR_ID}
style={{ position: "absolute", left: d.x - ox, top: d.y - oy, width: d.width, height: d.height }}
>
{d.children}
</View>
))}
{children}
</View>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { colsDetailed, type ColSpec } from "../engine/columns";
import { PlanError } from "../engine/errors";
import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids";
import { drawnText, type Measurer } from "../engine/measure";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { AvailableWidthProvider } from "./AvailableWidth";
import { pdfFlags } from "./pdfProps";
import { Cell, Row } from "./Row";
import { Txt } from "./Txt";
export interface TableRoles {
head: RoleName | TypeToken;
body: RoleName | TypeToken;
}
export interface TableLook {
/** Horizontal cell padding, pt per side. Default 0. */
padX?: number;
/** Vertical cell padding, pt per side. Default 4. */
padY?: number;
/** Bottom border of each row in pt (counted in the row height). Default 0. */
border?: number;
faceOverrides?: Readonly<Record<string, string>>;
}
export interface ItemsTablePlanInput extends TableLook {
columns: ColSpec[];
gap: number;
/** Table width in pt (the content width of the page, or of the cell holding the table). */
width: number;
header: string[];
rows: string[][];
measurer: Measurer;
roles: TableRoles;
/** Height of whatever follows the last row and must stay with it (the totals). */
tailH?: number;
/** Wrap-area height of one page. With `tailH` it lets the planner prove the tail can ever fit. */
contentH?: number;
}
export interface ItemsTablePlan {
widths: number[];
headH: number;
rowHeights: number[];
firstRowH: number;
/** Predicted line count per row (the tallest cell). */
rowLines: number[];
}
function cellHeight(m: Measurer, role: RoleName | TypeToken, text: string, inner: number, look: TableLook): { lines: number; height: number } {
const t = roleToken(role);
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
const lines = Math.max(
1,
m.wrapLines(drawnText(t, text), inner, {
face: resolveFace(t.face, look.faceOverrides),
weight: t.weight,
italic: t.italic ?? false,
size: t.size,
letterSpacing: (t.tracking ?? 0) * t.size,
}).length,
);
return { lines, height: lines * t.leading };
}
/**
* Plans an items table without rendering: column widths, the wrapped height of the header and of
* every row. Heights are estimates from the measurer (see Measurer.wrapLines) and feed
* TableGuard/BottomSpacer presence values.
*
* Throws PlanError("tail-too-tall") when the totals tail could never share a page with the header and
* the last row: `tailH <= contentH - headH - lastRowH`.
*/
export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
const { columns, gap, width, header, rows, measurer, roles, tailH, contentH } = input;
const padX = input.padX ?? 0;
const padY = input.padY ?? 4;
const border = input.border ?? 0;
if (header.length !== columns.length) throw new PlanError(`${header.length} header cells for ${columns.length} columns`, "invalid-plan");
if (rows.length === 0) throw new PlanError("an items table needs at least one row", "invalid-plan");
for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan");
const { widths } = colsDetailed(width, columns, gap);
const inner = widths.map((w) => Math.max(0, w - 2 * padX));
const heightOf = (role: RoleName | TypeToken, cells: string[]) => {
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input));
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border };
};
const headH = heightOf(roles.head, header).height;
const measured = rows.map((r) => heightOf(roles.body, r));
const rowHeights = measured.map((r) => r.height);
if (tailH !== undefined && contentH !== undefined) {
const lastRowH = rowHeights[rowHeights.length - 1];
if (tailH > contentH - headH - lastRowH + 1e-6) {
throw new PlanError(
`totals tail ${tailH} pt cannot share a ${contentH} pt page with the header (${headH}) and the last row (${lastRowH})`,
"tail-too-tall",
);
}
}
return { widths, headH, rowHeights, firstRowH: rowHeights[0], rowLines: measured.map((r) => r.lines) };
}
export interface TableRowsProps extends TableLook {
columns: ColSpec[];
gap: number;
width: number;
header: string[];
rows: string[][];
roles: TableRoles;
/** Per column; default left. */
align?: Array<"left" | "right" | "center">;
/** Passed as minPresenceAhead on the last row so the totals never start a page without an item row. */
tailH?: number;
/** Content after the last row, inside the table container (the totals). */
tail?: ReactNode;
headerStyle?: Record<string, unknown>;
rowStyle?: Record<string, unknown>;
borderColor?: string;
}
/**
* Header (a `fixed` first child, so it repeats on every page the table spans) followed by one
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
* the repeated header is positioned against the container's top.
*/
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, ...look }: TableRowsProps) {
const padX = look.padX ?? 0;
const padY = look.padY ?? 4;
const border = look.border ?? 0;
const { widths } = colsDetailed(width, columns, gap);
const cellStyle = (extra?: Record<string, unknown>) => ({
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
...extra,
});
const cells = (role: RoleName | TypeToken, values: string[]) =>
values.map((v, i) => (
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX }}>
<Txt role={role} width={Math.max(0, widths[i] - 2 * padX)} align={align[i]} faceOverrides={look.faceOverrides}>
{v}
</Txt>
</Cell>
));
return (
<AvailableWidthProvider width={width}>
<View>
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
{cells(roles.head, header)}
</Row>
{rows.map((r, i) => (
<Row
key={i}
id={`${TABLE_ROW_PREFIX}${i}`}
widths={columns}
gap={gap}
width={width}
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
style={cellStyle(rowStyle)}
>
{cells(roles.body, r)}
</Row>
))}
{tail}
</View>
</AvailableWidthProvider>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { View } from "@react-pdf/renderer";
import { chromeHeight } from "../engine/metrics";
import { getMeasurer, measureToken } from "../engine/measure";
import type { PageFrame } from "../engine/geometry";
import { BAND_ID, DECOR_ID, PAGE_LABEL_ID } from "../engine/ids";
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { PlanError } from "../engine/errors";
import type { DecorItem } from "./FirstPageHeader";
import { pdfFlags } from "./pdfProps";
import { RenderTxt } from "./RenderTxt";
export interface PageChromeProps {
frame: PageFrame;
/** Left footer text (e.g. the invoice number); omit for none. */
footerLeft?: string;
/** Show "Page n of N" at the right of the footer. Default true. */
pageLabel?: boolean;
/** Footer and band type; render-prop roles (no leading). Default "footer". */
role?: RoleName | TypeToken;
color?: string;
/** Continuation band for pages 2+ (shown only when pageNumber > 1). */
band?: { left?: string; right?: string };
/** Page-space top of the band. Default: the top of the reserve, pad.top - continuationReserve (>= 0). */
bandTop?: number;
/** Every-page decor in page space. */
decor?: DecorItem[];
}
/** "Page 3 of 5"; before the total is known (first layout pass) just "Page 3". */
export function pageLabelText(pageNumber: number, totalPages: number | undefined): string {
return typeof totalPages === "number" && totalPages > 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`;
}
/**
* Fixed chrome for every page. Put it AFTER the flow as the last child of the Page: with the legacy
* paginator a fixed node placed after the flow still repeats on every page.
*
* Everything is a page-level absolute anchored to the page edges, so none of it takes part in the flow:
* - footer: a row in the bottom margin, between the content bottom (page height - pad.bottom) and the
* physical margin. `frame.footerReserve` must cover chromeHeight(role); both texts are render-prop
* Text in an AUTO-height box (a fixed height smaller than the line draws nothing).
* - continuation band: only on pages 2+. On page 1 its render prop returns "" (0 lines, expected).
* - decor: fixed absolute boxes in page space, flagged with DECOR_ID.
*
* The page label has a pinned width taken from the widest label ("Page 99 of 99"), never from the
* current pass, because `totalPages` is undefined on the first pass and `subPageNumber` in the split pass.
*/
export function PageChrome({
frame,
footerLeft,
pageLabel = true,
role = "footer",
color,
band,
bandTop,
decor = [],
}: PageChromeProps) {
const { pad } = frame;
const need = chromeHeight(role);
const showFooter = pageLabel || footerLeft !== undefined;
if (showFooter && frame.footerReserve < need) {
throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve");
}
const labelWidth = Math.ceil(measureToken(getMeasurer(), role, "Page 99 of 99")) + 2;
const marginBottom = pad.bottom - frame.footerReserve;
const bandRightWidth = band?.right !== undefined ? Math.ceil(measureToken(getMeasurer(), role, band.right)) + 2 : 0;
const top = bandTop ?? Math.max(0, pad.top - frame.continuationReserve);
return (
<>
{decor.map((d, i) => (
<View
key={`decor-${i}`}
id={DECOR_ID}
style={{ position: "absolute", left: d.x, top: d.y, width: d.width, height: d.height }}
{...pdfFlags({ fixed: true })}
>
{d.children}
</View>
))}
{band && (band.left !== undefined || band.right !== undefined) && (
<View
style={{ position: "absolute", top, left: pad.left, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
{...pdfFlags({ fixed: true })}
>
{band.left !== undefined && (
<RenderTxt id={BAND_ID} role={role} color={color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
)}
{band.right !== undefined && (
<RenderTxt
id={BAND_ID}
role={role}
color={color}
align="right"
width={bandRightWidth}
render={({ pageNumber }) => (pageNumber > 1 ? (band.right ?? "") : "")}
/>
)}
</View>
)}
{showFooter && (
<View
style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
{...pdfFlags({ fixed: true })}
>
{footerLeft !== undefined && <RenderTxt role={role} color={color} grow render={() => footerLeft} />}
{pageLabel && (
<RenderTxt
id={PAGE_LABEL_ID}
role={role}
color={color}
align="right"
width={labelWidth}
render={({ pageNumber, totalPages }) => pageLabelText(pageNumber, totalPages)}
/>
)}
</View>
)}
</>
);
}
+4 -2
View File
@@ -8,6 +8,8 @@ export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput {
/** Receives { pageNumber, totalPages, subPageNumber, ... } from react-pdf. */
render: (info: { pageNumber: number; totalPages: number; subPageNumber: number; subPageTotalPages: number }) => string;
role: RoleName | TypeToken;
/** react-pdf copies the id into the layout tree (the audit finds chrome nodes by it). */
id?: string;
width?: number;
grow?: boolean;
style?: Record<string, unknown>;
@@ -20,7 +22,7 @@ export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput {
* metrics, never a fixed height. No maxLines: a clamped render-prop Text draws nothing.
* `totalPages` is undefined on the first layout pass, so the render function must cope with that.
*/
export function RenderTxt({ role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) {
export function RenderTxt({ id, role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) {
const token = roleToken(role);
assertNoLineHeight(style, "RenderTxt");
const merged = {
@@ -29,5 +31,5 @@ export function RenderTxt({ role, render, color, align, weight, italic, width, g
...(grow ? FILL : {}),
...style,
};
return <Text style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
return <Text {...(id !== undefined ? { id } : {})} style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
}
+4 -2
View File
@@ -5,6 +5,8 @@ import { AvailableWidthProvider, useAvailableWidth } from "./AvailableWidth";
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
export interface RowProps extends PdfFlagsInput {
/** Copied into the layout tree (audits find rows by it). */
id?: string;
/** One spec per child, in order. */
widths: ColSpec[];
gap?: number;
@@ -24,7 +26,7 @@ export interface RowProps extends PdfFlagsInput {
* cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is
* narrower than `stackBelow`), the children are stacked at full width instead.
*/
export function Row({ widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) {
export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) {
const inherited = useAvailableWidth();
const total = width ?? inherited;
const kids = Children.toArray(children);
@@ -32,7 +34,7 @@ export function Row({ widths, gap = 0, stackBelow, align = "flex-start", width,
const { widths: w, ok } = colsDetailed(total, widths, gap);
const stacked = !ok || (stackBelow !== undefined && total < stackBelow);
const flags = pdfFlags({ fixed, wrap, minPresenceAhead });
const flags = { ...(id !== undefined ? { id } : {}), ...pdfFlags({ fixed, wrap, minPresenceAhead }) };
if (stacked) {
return (
+19
View File
@@ -0,0 +1,19 @@
import { View } from "@react-pdf/renderer";
import { pdfFlags } from "./pdfProps";
/**
* Presence to ask for in front of a table: the header plus the first row, with the first row capped at
* three body lines so an oversize first row cannot push the table to a new page every time.
*/
export function guardPresence(headH: number, firstRowH: number, bodyLeading: number): number {
return headH + Math.min(firstRowH, 3 * bodyLeading);
}
/**
* Zero-height sentinel placed immediately before a table. react-pdf's `minPresenceAhead` measures from
* the element's bottom, so this moves itself (and with it the whole table) to the next page unless the
* header and the first rows fit below it. Without it the repeated header can stay alone at a page foot.
*/
export function TableGuard({ minPresenceAhead }: { minPresenceAhead: number }) {
return <View style={{ height: 0 }} {...pdfFlags({ minPresenceAhead })} />;
}
+206
View File
@@ -0,0 +1,206 @@
import { createElement as h } from "react";
import { Path, Svg, View } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { chromeHeight } from "../engine/metrics";
import { PlanError } from "../engine/errors";
import { auditLayout } from "../engine/audit";
import { computeFrame, PAGE_SIZES } from "../engine/geometry";
import { BAND_ID, DECOR_ID, PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids";
import { getMeasurer } from "../engine/measure";
import { TYPE_ROLES } from "../fonts/roles";
import { renderCore } from "../render/core";
import { byId, doc, find, frameFor, PAD } from "../testing/docs";
import { setupPdfTest } from "../testing/setup";
import { AvailableWidthProvider } from "./AvailableWidth";
import { FirstPageHeader } from "./FirstPageHeader";
import { planItemsTable, TableRows } from "./ItemsTable";
import { PageChrome, pageLabelText } from "./PageChrome";
import { guardPresence } from "./TableGuard";
setupPdfTest();
const rows = (n: number) => Array.from({ length: n }, (_, i) => h(View, { key: i, style: { height: 20 } }));
describe("FirstPageHeader", () => {
const pad = { top: PAD, right: PAD, bottom: PAD, left: PAD };
const decor = [{ x: 300, y: 8, width: 80, height: 40, children: h(Svg, { viewBox: "0 0 10 10", style: { width: 80, height: 40 } }, h(Path, { d: "M0 0 L10 0 L10 10 Z", fill: "#123456" })) }];
const page = (n: number) =>
doc([h(FirstPageHeader, { key: "h", pad, decor, style: { height: 60 } }, h(View, { style: { height: 10 } })), ...rows(n)]);
it("places absolute decor at the requested PAGE-space position, flagged as decor", async () => {
const r = await renderCore(page(8));
const d = byId(r.layout[0], DECOR_ID)[0];
expect(d.box.x).toBeCloseTo(300, 1);
expect(d.box.y).toBeCloseTo(8, 1);
expect(d.box.w).toBe(80);
expect(d.decor).toBe(true);
expect(find(r.layout[0], (n) => n.type === "svg" && !!n.decor)).toHaveLength(1);
});
it("keeps the decor on page 1 only, over a multi-page flow", async () => {
const r = await renderCore(page(30));
expect(r.pages).toBeGreaterThan(1);
expect(r.layout.map((p) => find(p, (n) => n.type === "svg").length)).toEqual(r.layout.map((_, i) => (i === 0 ? 1 : 0)));
});
it("honours an explicit pageOrigin", async () => {
const el = doc([
h(View, { key: "gap", style: { height: 30 } }),
h(FirstPageHeader, { key: "h", pad, pageOrigin: { x: PAD, y: 50 }, decor: [{ x: 100, y: 60, width: 20, height: 20 }], style: { height: 40 } }),
]);
const d = byId((await renderCore(el)).layout[0], DECOR_ID)[0];
expect([d.box.x, d.box.y]).toEqual([100, 60]);
});
});
describe("PageChrome", () => {
const FOOTER = chromeHeight("footer");
const frame = frameFor({ footerReserve: FOOTER, continuationReserve: 14 });
const padding = { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + FOOTER };
const el = () =>
doc(
[
...rows(40),
h(PageChrome, { key: "chrome", frame, footerLeft: "INV-0001", band: { left: "Invoice INV-0001", right: "continued" }, decor: [{ x: 0, y: 0, width: 400, height: 6 }] }),
],
{ pad: padding },
);
it("draws 'Page k of N' and the footer on every page, the band only from page 2, and audits clean", async () => {
const r = await renderCore(el(), { frame });
expect(r.pages).toBeGreaterThan(2);
r.layout.forEach((p, i) => {
expect(byId(p, PAGE_LABEL_ID)[0].lines!.map((l) => l.text)).toEqual([`Page ${i + 1} of ${r.pages}`]);
const band = byId(p, BAND_ID).flatMap((n) => n.lines!.map((l) => l.text));
expect(band).toEqual(i === 0 ? [] : ["Invoice INV-0001", "continued"]);
expect(byId(p, DECOR_ID)).toHaveLength(1);
});
expect(r.issues).toEqual([]);
});
it("puts the footer line inside the reserved strip below the content area", async () => {
const r = await renderCore(el());
const label = byId(r.layout[0], PAGE_LABEL_ID)[0];
const contentBottom = 300 - frame.pad.bottom;
expect(label.box.y).toBeGreaterThanOrEqual(contentBottom - 0.01);
expect(label.box.y + label.box.h).toBeLessThanOrEqual(300 - PAD + 0.01);
});
it("rejects a footer reserve smaller than the line", () => {
const tight = frameFor({ footerReserve: 4 });
expect(() => PageChrome({ frame: tight })).toThrow(PlanError);
});
it("copes with totalPages being undefined on the first pass", () => {
expect(pageLabelText(2, undefined)).toBe("Page 2");
expect(pageLabelText(2, 0)).toBe("Page 2");
expect(pageLabelText(2, 5)).toBe("Page 2 of 5");
});
});
describe("planItemsTable / TableRows", () => {
const WIDTH = 360;
const columns = [{ fill: true as const }, { fixed: 60 }];
const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody };
const body = [
["Short", "1"],
["A much longer item description that has to wrap onto several lines in a narrow description column", "2"],
["Mid length item", "3"],
];
const plan = () => planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 });
it("allocates the columns exactly and sizes rows from wrapped lines", () => {
const p = plan();
expect(p.widths.reduce((a, b) => a + b, 0) + 8).toBeCloseTo(WIDTH, 2);
expect(p.rowLines[0]).toBe(1);
expect(p.rowLines[1]).toBeGreaterThan(1);
expect(p.rowHeights[0]).toBeCloseTo(13.8 + 8 + 0.5, 5);
expect(p.firstRowH).toBe(p.rowHeights[0]);
expect(p.headH).toBeCloseTo(10 + 8 + 0.5, 5);
});
it("matches the heights react-pdf lays out", async () => {
const p = plan();
const el = doc(
h(AvailableWidthProvider, { width: WIDTH }, h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, roles, padY: 4, border: 0.5 })),
);
const r = await renderCore(el);
const page = r.layout[0];
expect(byId(page, TABLE_HEAD_ID)[0].box.h).toBeCloseTo(p.headH, 1);
body.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1));
});
it("throws a typed PlanError when the totals tail can never share a page with the header and last row", () => {
const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 };
const p = plan();
const lastRow = p.rowHeights[p.rowHeights.length - 1];
const ok = p.headH + lastRow + 100;
expect(() => planItemsTable({ ...base, tailH: 100, contentH: ok })).not.toThrow();
try {
planItemsTable({ ...base, tailH: 100, contentH: ok - 1 });
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(PlanError);
expect((e as PlanError).code).toBe("tail-too-tall");
}
});
it("rejects malformed plans", () => {
const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], measurer: getMeasurer(), roles };
expect(() => planItemsTable({ ...base, rows: [] })).toThrow(PlanError);
expect(() => planItemsTable({ ...base, rows: [["only one"]] })).toThrow(PlanError);
expect(() => planItemsTable({ ...base, rows: [["a\nb", "1"]] })).toThrow(PlanError);
});
it("the last row carries the tail's minPresenceAhead: totals never start a page without an item row", async () => {
const many = Array.from({ length: 24 }, (_, i) => [`Item ${i + 1}`, `${i + 1}`]);
const p = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: many, measurer: getMeasurer(), roles, padY: 4 });
const TAIL = 60;
// Fill page 1 so the last row would land right at the foot with the tail not fitting after it.
const build = (tailH?: number) =>
doc(
h(
AvailableWidthProvider,
{ width: WIDTH },
h(
TableRows,
{ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: many, roles, padY: 4, tailH, tail: h(View, { id: "totals", style: { height: TAIL }, wrap: false }) },
),
),
{ height: Math.ceil(PAD * 2 + p.headH + p.rowHeights.reduce((a, b) => a + b, 0) + 10) },
);
const withTail = await renderCore(build(TAIL));
const totalsPage = withTail.layout.findIndex((pg) => byId(pg, "totals").length > 0);
const rowsOnTotalsPage = find(withTail.layout[totalsPage], (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX)).length;
expect(rowsOnTotalsPage).toBeGreaterThan(0);
const without = await renderCore(build(undefined));
const wp = without.layout.findIndex((pg) => byId(pg, "totals").length > 0);
expect(find(without.layout[wp], (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX))).toHaveLength(0);
});
it("guardPresence caps the first row at three body lines", () => {
expect(guardPresence(18, 20, 13.8)).toBeCloseTo(38, 5);
expect(guardPresence(18, 200, 13.8)).toBeCloseTo(18 + 3 * 13.8, 5);
});
});
describe("audit of a full block composition", () => {
it("a framed page with chrome and table is clean", async () => {
const FOOTER = chromeHeight("footer");
const frame = computeFrame({ page: { ...PAGE_SIZES.A4, width: 400, height: 300 }, margins: { top: PAD, right: PAD, bottom: PAD, left: PAD }, footerReserve: FOOTER, continuationReserve: 14, letterMode: false });
const WIDTH = frame.contentWidth;
const cols = [{ fill: true as const }, { fixed: 50 }];
const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody };
const items = Array.from({ length: 40 }, (_, i) => [`Item number ${i + 1}`, `${i + 1}`]);
const el = doc(
[
h(AvailableWidthProvider, { key: "t", width: WIDTH }, h(TableRows, { columns: cols, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: items, roles, padY: 3 })),
h(PageChrome, { key: "c", frame, footerLeft: "INV-1", band: { left: "INV-1", right: "continued" } }),
],
{ pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + FOOTER } },
);
const r = await renderCore(el);
expect(auditLayout({ pages: r.layout }, frame, {})).toEqual([]);
});
});
+276
View File
@@ -0,0 +1,276 @@
import { createElement as h, type ReactNode } from "react";
import { Image, Svg, Text, View } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { BottomSpacer } from "../blocks/BottomSpacer";
import { PageChrome } from "../blocks/PageChrome";
import { RenderTxt } from "../blocks/RenderTxt";
import { Tracked } from "../blocks/Tracked";
import { AvailableWidthProvider } from "../blocks/AvailableWidth";
import { renderCore } from "../render/core";
import { byId, doc, frameFor, PAD, txt } from "../testing/docs";
import { setupPdfTest } from "../testing/setup";
import { auditLayout, type AuditCode, type AuditOptions } from "./audit";
import { BAND_ID, DECOR_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./ids";
setupPdfTest();
const FRAME = frameFor();
/** Renders `el` and audits it against `frame`; returns the issue codes (and the issues themselves). */
async function audit(el: ReturnType<typeof doc>, opts: AuditOptions = {}, frame = FRAME) {
const r = await renderCore(el);
const issues = auditLayout({ pages: r.layout }, frame, opts);
return { codes: issues.map((i) => i.code) as AuditCode[], issues, r };
}
const LONG = "Aurora Industrial Fasteners and Precision Components Private Limited, Plot 14, Phase 2, Peenya Industrial Area, Bengaluru, Karnataka";
const abs = (style: Record<string, unknown>) => ({ position: "absolute" as const, ...style });
describe("auditLayout", () => {
describe("overlap", () => {
it("flags two text nodes whose line boxes intersect", async () => {
const el = doc([
h(Text, { key: "a", style: txt() }, "Vendor address line"),
h(Text, { key: "b", style: txt({ ...abs({ top: 22, left: 40 }) }) }, "INVOICE"),
]);
const { codes, issues } = await audit(el);
expect(codes).toContain("overlap");
expect(issues.find((i) => i.code === "overlap")!.box).toBeDefined();
});
it("passes stacked text, and ignores a text overlapping decor", async () => {
expect((await audit(doc([h(Text, { key: "a", style: txt() }, "One"), h(Text, { key: "b", style: txt() }, "Two")]))).codes).toEqual([]);
const withDecor = doc([
h(Text, { key: "a", style: txt() }, "Vendor address line"),
h(View, { key: "d", id: DECOR_ID, style: abs({ top: 20, left: 20 }) }, h(Text, { style: txt() }, "Vendor address line")),
]);
expect((await audit(withDecor)).codes).toEqual([]);
});
});
describe("overflow-x", () => {
const row = (fill: Record<string, unknown>) =>
doc(
h(
View,
{ style: { flexDirection: "row", width: 360 } },
h(Text, { style: txt(fill) }, LONG),
h(View, { style: { width: 180, minWidth: 180, maxWidth: 180 } }, h(Text, { style: txt() }, "side")),
),
);
it("flags text whose stale line breaks run wider than its final box", async () => {
const { codes, issues } = await audit(row({ flexGrow: 1 }));
expect(codes).toContain("overflow-x");
expect(issues.some((i) => i.code === "overlap")).toBe(true);
});
it("passes the same row with flexBasis 0", async () => {
expect((await audit(row({ flexGrow: 1, flexBasis: 0 }))).codes).toEqual([]);
});
it("measures ink: tracked right-aligned text may overhang its box by the trailing letterSpacing", async () => {
const el = doc(
h(
AvailableWidthProvider,
{ width: 200 },
h(View, { style: { width: 200 } }, h(Tracked, { role: "title", align: "right" }, "Invoice")),
),
);
const { codes, r } = await audit(el);
expect(codes).toEqual([]);
// The box does overhang the page margin by one tracking step; the ink does not.
const t = r.layout[0].nodes[0].children[0];
expect(t.box.x + t.box.w).toBeGreaterThan(PAD + 200 + 0.5);
});
it("flags ink beyond the page margin box", async () => {
const el = doc(h(Text, { style: txt({ width: 500 }) }, "Short"), { pad: PAD });
expect((await audit(doc(h(Text, { style: txt({ ...abs({ left: 340 }) }) }, "Overruns the right margin")))).codes).toContain("overflow-x");
expect((await audit(el)).codes).toEqual([]);
});
});
describe("outside-safe-zone", () => {
it("flags a non-decor, non-fixed node outside the margin box", async () => {
const el = doc(h(View, { id: "stray", style: abs({ top: 2, left: 2, width: 30, height: 10 }) }));
const { codes, issues } = await audit(el);
expect(codes).toContain("outside-safe-zone");
expect(issues[0].message).toContain("#stray");
});
it("allows decor anywhere and fixed chrome inside the page edge, but not beyond it", async () => {
const decor = doc(h(View, { id: DECOR_ID, style: abs({ top: -5, left: -5, width: 30, height: 10 }) }));
expect((await audit(decor)).codes).toEqual([]);
const chrome = (top: number) => doc(h(View, { fixed: true, style: abs({ top, left: 4, width: 30, height: 10 }) }));
expect((await audit(chrome(2))).codes).toEqual([]);
expect((await audit(chrome(-4))).codes).toContain("outside-safe-zone");
});
});
describe("below-wrap-area", () => {
it("flags non-fixed content that ends under the content bottom", async () => {
// The page really has no bottom padding, but the frame says the wrap area ends 20 pt up.
const el = doc(h(View, { id: "tall", style: { height: 285 } }), { pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: 0 } });
const { codes, issues } = await audit(el);
expect(codes).toContain("below-wrap-area");
expect(issues.find((i) => i.code === "below-wrap-area")!.message).toContain("#tall");
});
it("passes content inside the wrap area, fixed chrome below it and decor below it", async () => {
const el = doc([
h(View, { key: "a", style: { height: 100 } }),
h(View, { key: "b", fixed: true, style: abs({ bottom: 4, left: PAD, width: 40, height: 10 }) }),
h(View, { key: "c", id: DECOR_ID, style: abs({ bottom: 0, left: PAD, width: 40, height: 10 }) }),
]);
expect((await audit(el)).codes).toEqual([]);
});
});
describe("text-truncated", () => {
const name = "Aurora Industrial Fasteners and Precision Components Private Limited of Peenya";
const box = (sizing: Record<string, unknown>, style: Record<string, unknown> = {}) =>
doc(h(View, { style: { width: 100, ...sizing } }, h(Text, { style: txt(style) }, name)));
it("flags lines lost to a pinned height, with the unexpected ellipsis noted", async () => {
const { issues } = await audit(box({ height: 33 }));
const t = issues.find((i) => i.code === "text-truncated")!;
expect(t.severity).toBe("error");
expect(t.message).toContain("unexpected ellipsis");
});
it("passes a minHeight box, and text that really contains an ellipsis", async () => {
expect((await audit(box({ minHeight: 33 }))).codes).toEqual([]);
expect((await audit(doc(h(Text, { style: txt() }, "Wait… what")))).codes).toEqual([]);
});
it("downgrades an authored maxLines clamp to a warning", async () => {
const { issues } = await audit(box({}, { maxLines: 2, textOverflow: "ellipsis" }));
expect(issues.filter((i) => i.code === "text-truncated").map((i) => i.severity)).toEqual(["warn"]);
});
it("compares through whitespace and caps: uppercase transform and trailing spaces are not truncation", async () => {
expect((await audit(doc(h(Text, { style: txt({ textTransform: "uppercase", width: 60 }) }, "tax invoice for goods")))).codes).toEqual([]);
});
});
describe("render-text-empty", () => {
const role = { face: "IBM Plex Sans" as const, weight: 400 as const, size: 8 };
const chrome = (viewStyle: Record<string, unknown>) =>
doc([
h(View, { key: "x", style: { height: 400 } }),
h(View, { key: "c", fixed: true, style: abs({ bottom: 4, left: PAD, width: 100, ...viewStyle }) }, h(RenderTxt, { role, render: ({ pageNumber }) => `Page ${pageNumber}` })),
]);
it("flags render-prop text that drew 0 lines in a too-short fixed box", async () => {
expect((await audit(chrome({ height: 10 }))).codes).toContain("render-text-empty");
});
it("passes the same text in an auto-height box", async () => {
expect((await audit(chrome({}))).codes).toEqual([]);
});
it("lets the continuation band be empty on page 1 only", async () => {
const el = doc(
[
h(View, { key: "x", style: { height: 600 } }),
h(PageChrome, { key: "chrome", frame: frameFor({ footerReserve: 14 }), pageLabel: false, band: { left: "Invoice INV-1", right: "continued" } }),
],
{ pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + 14 } },
);
const { codes, r } = await audit(el, {}, frameFor({ footerReserve: 14 }));
expect(r.pages).toBeGreaterThan(1);
expect(codes).toEqual([]);
expect(byId(r.layout[0], BAND_ID).every((n) => n.lines!.length === 0)).toBe(true);
expect(byId(r.layout[1], BAND_ID).every((n) => n.lines!.length > 0)).toBe(true);
// The same empty text on page 2 would be an error: a custom id list does not excuse it.
const strict = auditLayout({ pages: r.layout }, frameFor({ footerReserve: 14 }), { optionalFirstPageIds: [] });
expect(strict.map((i) => i.code)).toContain("render-text-empty");
});
});
describe("header-only-page", () => {
const table = (fillerH: number) =>
doc([
h(View, { key: "f", style: { height: fillerH } }),
h(
View,
{ key: "t" },
h(View, { key: "h", id: TABLE_HEAD_ID, fixed: true, style: { height: 10 } }),
...Array.from({ length: 12 }, (_, i) => h(View, { key: i, id: `${TABLE_ROW_PREFIX}${i}`, style: { height: 20 }, wrap: false })),
),
]);
it("flags a page whose only table content is the repeated header", async () => {
const { issues } = await audit(table(250));
expect(issues.find((i) => i.code === "header-only-page")!.page).toBe(1);
});
it("passes when every page with the header has rows", async () => {
expect((await audit(table(100))).codes).toEqual([]);
});
});
describe("keepout", () => {
const el = () => doc([h(Text, { key: "a", style: txt() }, "Near the corner"), h(Text, { key: "b", style: txt() }, "Second")]);
it("flags text lines entering a keep-out rectangle", async () => {
const { issues } = await audit(el(), { keepOuts: [{ x: 0, y: 0, w: 120, h: 30 }] });
expect(issues.filter((i) => i.code === "keepout").length).toBeGreaterThan(0);
});
it("passes content clear of the rectangle, a rectangle on another page, and decor inside it", async () => {
expect((await audit(el(), { keepOuts: [{ x: 200, y: 100, w: 100, h: 100 }] })).codes).toEqual([]);
expect((await audit(el(), { keepOuts: [{ x: 0, y: 0, w: 120, h: 30, page: 2 }] })).codes).toEqual([]);
const decor = doc(h(Svg, { id: DECOR_ID, style: abs({ top: 20, left: 20, width: 50, height: 20 }) }));
expect((await audit(decor, { keepOuts: [{ x: 0, y: 0, w: 120, h: 60 }] })).codes).toEqual([]);
});
it("also checks images and Svg", async () => {
const png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
const withImage = doc(h(Image, { src: png, style: { width: 40, height: 20 } }));
expect((await audit(withImage, { keepOuts: [{ x: 0, y: 0, w: 120, h: 60 }] })).codes).toContain("keepout");
});
});
describe("split-block", () => {
// Four 60 pt rows fill the wrap area to 260 of 280; the fifth straddles the foot.
const rows = (fifth: Record<string, unknown>) =>
doc([0, 1, 2, 3, 4].map((i) => h(View, { key: i, id: `row-${i}`, style: { height: 60 }, ...(i === 4 ? fifth : { wrap: false }) }, h(Text, { style: txt() }, `R${i}`))));
it("flags a wrap={false} block that appears on two pages", async () => {
const { issues } = await audit(rows({ wrap: false, fixed: false }));
expect(issues.find((i) => i.code === "split-block")!.message).toContain("#row-4");
});
it("passes a wrap={false} block that moved whole, and ignores fixed chrome repeating on every page", async () => {
expect((await audit(rows({ wrap: false }))).codes).toEqual([]);
const el = doc([
...Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } })),
h(View, { key: "f", id: "footer", fixed: true, style: abs({ bottom: 4, left: PAD, width: 20, height: 10 }), wrap: false }),
]);
expect((await audit(el)).codes).toEqual([]);
});
});
describe("closing-not-at-bottom", () => {
const body = (spacer: ReactNode) =>
doc(
[
...Array.from({ length: 16 }, (_, i) => h(View, { key: i, style: { height: 20 } })),
spacer,
h(View, { key: "closing", id: "closing", style: { height: 50 }, wrap: false }, h(Text, { style: txt() }, "Amount in words")),
],
);
it("flags a closing block that is not at the wrap-area bottom of the last page", async () => {
const { issues } = await audit(body(null), { closingId: "closing" });
expect(issues.find((i) => i.code === "closing-not-at-bottom")!.page).toBe(2);
});
it("passes with a BottomSpacer, and reports a closing block missing from the last page", async () => {
expect((await audit(body(h(BottomSpacer, { key: "sp", closingH: 50 })), { closingId: "closing" })).codes).toEqual([]);
const { issues } = await audit(body(null), { closingId: "nope" });
expect(issues.find((i) => i.code === "closing-not-at-bottom")!.message).toContain("not on the last page");
});
});
});
+226
View File
@@ -0,0 +1,226 @@
import type { PageFrame } from "./geometry";
import { BAND_ID, DECOR_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./ids";
import { walk, type LayoutLine, type LayoutNode, type LayoutPage, type Rect } from "./layoutTree";
export type AuditCode =
| "overlap"
| "overflow-x"
| "outside-safe-zone"
| "below-wrap-area"
| "text-truncated"
| "render-text-empty"
| "header-only-page"
| "keepout"
| "split-block"
| "closing-not-at-bottom";
export interface AuditIssue {
code: AuditCode;
severity: "error" | "warn";
/** 1-based. */
page: number;
message: string;
box?: Rect;
}
export interface AuditOptions {
/** Rectangles (page space) that no content may touch; `page` limits one to a single 1-based page. */
keepOuts?: Array<Rect & { page?: number }>;
/** Id of the closing block; its bottom must sit on the wrap-area bottom of the last page. */
closingId?: string;
/** Render-prop text ids that may draw nothing on page 1 (the continuation band). */
optionalFirstPageIds?: string[];
/** Header row id and body row id prefix for header-only-page. */
headId?: string;
rowPrefix?: string;
}
/** Overlaps and overruns smaller than this are rounding noise. */
const TOL = 0.5;
const right = (r: Rect) => r.x + r.w;
const bottom = (r: Rect) => r.y + r.h;
const intersects = (a: Rect, b: Rect, tol = TOL) =>
Math.min(right(a), right(b)) - Math.max(a.x, b.x) > tol && Math.min(bottom(a), bottom(b)) - Math.max(a.y, b.y) > tol;
/** Where the glyphs really are: the advance minus the letterSpacing step that follows the last glyph. */
function inkRect(line: LayoutLine, node: LayoutNode): Rect {
const ls = node.letterSpacing ?? 0;
return { x: line.x, y: line.y, w: Math.max(0, line.w - ls), h: line.h };
}
const squash = (s: string) => s.replace(/[\u00ad\u2028\u2029\u2060]/g, "").replace(/\s+/g, "");
const fmt = (r: Rect) => `[x ${r.x.toFixed(1)}..${right(r).toFixed(1)}, y ${r.y.toFixed(1)}..${bottom(r).toFixed(1)}]`;
const label = (n: LayoutNode) => `${n.id ? `#${n.id} ` : ""}${n.type}${n.text ? ` "${n.text.slice(0, 40)}"` : ""}`;
/**
* Checks the laid-out document against the frame. Pure: reads only the normalised tree. Every check
* ignores decor (DECOR_ID subtrees) unless noted.
*/
export function auditLayout(layout: { pages: LayoutPage[] }, frame: PageFrame, opts: AuditOptions = {}): AuditIssue[] {
const issues: AuditIssue[] = [];
const optionalFirst = new Set(opts.optionalFirstPageIds ?? [BAND_ID]);
const headId = opts.headId ?? TABLE_HEAD_ID;
const rowPrefix = opts.rowPrefix ?? TABLE_ROW_PREFIX;
const { pad } = frame;
const marginBottom = pad.bottom - frame.footerReserve;
layout.pages.forEach((page, idx) => {
const pageNo = idx + 1;
const add = (code: AuditCode, message: string, box?: Rect, severity: "error" | "warn" = "error") =>
issues.push({ code, severity, page: pageNo, message, ...(box ? { box } : {}) });
const margin: Rect = { x: pad.left, y: pad.top, w: page.width - pad.left - pad.right, h: page.height - pad.top - marginBottom };
const pageRect: Rect = { x: 0, y: 0, w: page.width, h: page.height };
const contentBottom = page.height - pad.bottom;
const all: Array<{ node: LayoutNode; parent: LayoutNode | undefined }> = [];
walk(page.nodes, (node, parent) => all.push({ node, parent }));
const live = all.filter((e) => !e.node.decor);
// overlap: lines of two different text nodes.
const lines: Array<{ node: LayoutNode; line: LayoutLine }> = [];
for (const { node } of live) {
if (node.type !== "text") continue;
for (const line of node.lines ?? []) if (line.text.trim() !== "") lines.push({ node, line });
}
for (let i = 0; i < lines.length; i++) {
for (let j = i + 1; j < lines.length; j++) {
if (lines[i].node === lines[j].node) continue;
const a = inkRect(lines[i].line, lines[i].node);
const b = inkRect(lines[j].line, lines[j].node);
if (intersects(a, b)) {
add("overlap", `"${lines[i].line.text}" ${fmt(a)} overlaps "${lines[j].line.text}" ${fmt(b)}`, a);
}
}
}
for (const { node, parent } of live) {
if (node.type === "text") {
const ink = (node.lines ?? []).map((l) => inkRect(l, node)).filter((r) => r.w > 0);
// overflow-x: ink past its own box, its container, or the page margin box.
for (const [i, r] of ink.entries()) {
const ln = node.lines![i];
const text = `"${ln.text}"`;
if (right(r) > right(node.box) + TOL || r.x < node.box.x - TOL) {
add("overflow-x", `${text} ink ${fmt(r)} runs outside its own box ${fmt(node.box)} (stale line breaks?)`, r);
} else if (parent && parent.type === "view" && (right(r) > right(parent.box) + TOL || r.x < parent.box.x - TOL)) {
add("overflow-x", `${text} ink ${fmt(r)} runs outside its container ${fmt(parent.box)}`, r);
} else if (right(r) > right(margin) + TOL || r.x < margin.x - TOL) {
add("overflow-x", `${text} ink ${fmt(r)} runs outside the margin box ${fmt(margin)}`, r);
}
}
// text-truncated
if (!node.render && node.text !== undefined) {
const want = squash(node.text);
const got = squash((node.lines ?? []).map((l) => l.text).join(" "));
if (want !== got) {
const ellipsis = got.includes("…") && !want.includes("…");
add(
"text-truncated",
`${label(node)} drew "${(node.lines ?? []).map((l) => l.text).join(" ")}"${ellipsis ? " (unexpected ellipsis)" : ""}`,
node.box,
node.maxLines ? "warn" : "error",
);
}
}
// render-text-empty
if (node.render && ink.length === 0 && !(pageNo === 1 && node.id !== undefined && optionalFirst.has(node.id))) {
add("render-text-empty", `render-prop text ${label(node)} drew 0 lines (a pinned height, or a lineHeight on an ancestor?)`, node.box);
}
}
// outside-safe-zone: text by its ink, everything else by its box.
const extent: Rect | null =
node.type === "text"
? (() => {
const rs = (node.lines ?? []).map((l) => inkRect(l, node)).filter((r) => r.w > 0);
if (rs.length === 0) return null;
const x0 = Math.min(...rs.map((r) => r.x));
const y0 = Math.min(...rs.map((r) => r.y));
return { x: x0, y: y0, w: Math.max(...rs.map(right)) - x0, h: Math.max(...rs.map(bottom)) - y0 };
})()
: node.box;
if (extent) {
// Chrome (fixed) may use the page edge; everything else stays inside the margins.
const zone = node.fixed ? pageRect : margin;
if (extent.x < zone.x - TOL || extent.y < zone.y - TOL || right(extent) > right(zone) + TOL || bottom(extent) > bottom(zone) + TOL) {
add("outside-safe-zone", `${label(node)} ${fmt(extent)} is outside ${node.fixed ? "the page" : "the margin box"} ${fmt(zone)}`, extent);
}
}
// keepout: text lines, images and svg only (containers span their children).
for (const k of opts.keepOuts ?? []) {
if (k.page !== undefined && k.page !== pageNo) continue;
const parts: Rect[] =
node.type === "text" ? (node.lines ?? []).map((l) => inkRect(l, node)) : node.type === "image" || node.type === "svg" ? [node.box] : [];
for (const part of parts) {
if (intersects(part, k)) add("keepout", `${label(node)} ${fmt(part)} enters the keep-out ${fmt(k)}`, part);
}
}
}
// below-wrap-area: the deepest non-chrome nodes that end under the content bottom.
const below = (n: LayoutNode): boolean => {
if (n.decor || n.fixed) return false;
const childBelow = n.children.map(below).some(Boolean);
const isBelow = bottom(n.box) > contentBottom + TOL;
if (isBelow && !childBelow) {
add("below-wrap-area", `${label(n)} ends at y ${bottom(n.box).toFixed(1)}, under the content bottom ${contentBottom.toFixed(1)}`, n.box);
}
return isBelow;
};
page.nodes.forEach(below);
// header-only-page
const hasHead = all.some((e) => e.node.id === headId);
const hasRow = all.some((e) => e.node.id?.startsWith(rowPrefix));
if (hasHead && !hasRow) add("header-only-page", "the only table content on this page is the repeated header row");
});
// split-block: a non-chrome id on two pages where it was authored wrap={false}.
const seen = new Map<string, { pages: Set<number>; noWrap: boolean; box: Rect }>();
layout.pages.forEach((page, idx) => {
walk(page.nodes, (n) => {
if (!n.id || n.fixed || n.decor || n.id === DECOR_ID) return;
const e = seen.get(n.id) ?? { pages: new Set<number>(), noWrap: false, box: n.box };
e.pages.add(idx + 1);
e.noWrap ||= !!n.noWrap;
seen.set(n.id, e);
});
});
for (const [id, e] of seen) {
if (e.pages.size > 1 && e.noWrap) {
const pages = [...e.pages];
issues.push({ code: "split-block", severity: "error", page: pages[0], message: `wrap={false} block #${id} appears on pages ${pages.join(" and ")}`, box: e.box });
}
}
// closing-not-at-bottom
if (opts.closingId && layout.pages.length > 0) {
const lastIdx = layout.pages.length - 1;
const last = layout.pages[lastIdx];
let found: LayoutNode | undefined;
walk(last.nodes, (n) => {
if (n.id === opts.closingId) found = n;
});
const want = last.height - pad.bottom;
if (!found) {
issues.push({ code: "closing-not-at-bottom", severity: "error", page: lastIdx + 1, message: `closing block #${opts.closingId} is not on the last page` });
} else if (Math.abs(bottom((found as LayoutNode).box) - want) > TOL) {
issues.push({
code: "closing-not-at-bottom",
severity: "error",
page: lastIdx + 1,
message: `closing block #${opts.closingId} ends at y ${bottom((found as LayoutNode).box).toFixed(1)}, the wrap area ends at ${want.toFixed(1)}`,
box: (found as LayoutNode).box,
});
}
}
return issues;
}
+12
View File
@@ -0,0 +1,12 @@
export type PlanErrorCode = "tail-too-tall" | "chrome-reserve" | "invalid-plan";
/** A layout plan that cannot work (content that can never fit), reported before rendering. */
export class PlanError extends Error {
constructor(
message: string,
readonly code: PlanErrorCode,
) {
super(message);
this.name = "PlanError";
}
}
+73
View File
@@ -0,0 +1,73 @@
import { walk, type LayoutNode, type LayoutPage } from "./layoutTree";
const r1 = (n: number) => Math.round(n * 10) / 10;
/**
* The canonical text that gets hashed: page sizes, every text node's box (rounded to 0.1 pt) and its
* line strings, and image boxes. Never the raw PDF bytes: Creator/Producer feed the PDF /ID, so the
* bytes change with every app update even when the pages do not.
*/
export function canonicalLayout(layout: { pages: LayoutPage[] }): string {
const pages = layout.pages.map((p) => {
const items: unknown[] = [];
walk(p.nodes, (n: LayoutNode) => {
if (n.type === "text") items.push(["t", r1(n.box.x), r1(n.box.y), r1(n.box.w), r1(n.box.h), (n.lines ?? []).map((l) => l.text)]);
else if (n.type === "image") items.push(["i", r1(n.box.x), r1(n.box.y), r1(n.box.w), r1(n.box.h)]);
});
return [r1(p.width), r1(p.height), items];
});
return JSON.stringify(pages);
}
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
/** Plain-JS SHA-256 so the fingerprint is identical where crypto.subtle is missing (insecure contexts). */
export function sha256HexJs(bytes: Uint8Array): string {
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6);
padded.set(bytes);
padded[bytes.length] = 0x80;
const view = new DataView(padded.buffer);
view.setUint32(padded.length - 8, Math.floor((bytes.length * 8) / 2 ** 32));
view.setUint32(padded.length - 4, (bytes.length * 8) >>> 0);
const w = new Uint32Array(64);
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
}
let [a, b, c, d, e, f, g, hh] = h;
for (let i = 0; i < 64; i++) {
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0;
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
}
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
}
return [...h].map((x) => x.toString(16).padStart(8, "0")).join("");
}
/** SHA-256 hex of a string: crypto.subtle when present, else the JS fallback. Always async. */
export async function sha256Hex(text: string): Promise<string> {
const bytes = new TextEncoder().encode(text);
const subtle = globalThis.crypto?.subtle;
if (subtle) {
const digest = await subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((x) => x.toString(16).padStart(2, "0")).join("");
}
return sha256HexJs(bytes);
}
export async function layoutFingerprint(layout: { pages: LayoutPage[] }): Promise<string> {
return sha256Hex(canonicalLayout(layout));
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Ids the audit recognises. react-pdf copies `id` into the layout tree, so these survive to
* auditLayout without any custom prop.
*/
/** Marks decor (absolute decorative boxes). The audit ignores a decor node and everything under it. */
export const DECOR_ID = "decor";
/** The continuation band text: legitimately draws 0 lines on page 1. */
export const BAND_ID = "chrome-band";
/** The "Page n of N" label. */
export const PAGE_LABEL_ID = "chrome-page";
/** The repeated table header row (fixed). */
export const TABLE_HEAD_ID = "tbl-head";
/** Body rows are `tbl-row-<index>`: unique, so a row on two pages is visible as a split. */
export const TABLE_ROW_PREFIX = "tbl-row-";
+204
View File
@@ -0,0 +1,204 @@
/**
* Normalised layout tree. react-pdf 4.9.0 hands `Document onRender` the whole laid-out tree as
* `_INTERNAL__LAYOUT__DATA_`; this module turns it into a small typed structure the audit and the
* fingerprint read, so nothing else depends on react-pdf internals.
*
* The internal tree (verified by dumping real renders, layout 5.2.0 / textkit 7.0.1):
*
* DOCUMENT { props, children: PAGE[] }
* PAGE { box: { width, height, top: 0, left: 0, padding* ... }, children } one per physical page
* VIEW | TEXT | IMAGE | SVG | LINK | ... { type, props, style, box, children, wasSplit, origin }
* TEXT also has `lines: Line[]`; its children are TEXT_INSTANCE { value } (the source strings)
* Line { string, box: { x, y, width, height }, xAdvance, height, ascent, overflowRight, runs }
*
* - `box.top` / `box.left` are relative to the PARENT's border box (Yoga). Absolute page coordinates are
* the sum down the chain; `origin` is the transform centre, not a position.
* - `box.width` / `box.height` are the final sizes after flex. `style.lineHeight` of a static Text with
* an 'Npt' value is rewritten to N*1000 (13.8pt -> 13800): read line boxes, never the style.
* - Line `box.x` is the aligned start of the line inside the text content box; `box.y` stacks lines
* from the top. `box.width` is NOT the ink width (it spans the container plus the overflow). The
* advance is `xAdvance`: glyph advances including the letterSpacing after the last glyph and any
* leading/trailing whitespace, which `overflowLeft`/`overflowRight` account for. Ink width is
* `xAdvance - overflowLeft - overflowRight` (minus the trailing letterSpacing step).
* - `props` keeps `id`, `fixed`, `wrap`, `minPresenceAhead` and `render` as the author wrote them. A node
* that the paginator moved to the next page gets `wrap: true, break: false` written over its props.
* - Fixed nodes are copied onto every page; each page has its own PAGE node with only the pieces on it.
* - SVG children (paths, groups) carry no layout boxes; the SVG node is a leaf here.
*
* Normalised form: every node has an ABSOLUTE page-space box and a lower-case `type`. TEXT_INSTANCE nodes
* disappear into the owning text's `text` (the source string, textTransform applied). Text lines get
* absolute boxes whose width is the advance width without edge whitespace. `decor` is inherited from any DECOR_ID ancestor.
*/
import { DECOR_ID } from "./ids";
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
export interface LayoutLine extends Rect {
text: string;
}
export type LayoutNodeType = "view" | "text" | "image" | "svg" | "link" | "other";
export interface LayoutNode {
type: LayoutNodeType;
id?: string;
/** Absolute page-space border box. */
box: Rect;
/** Text nodes: the source string (what should have been drawn). Render-prop text: the drawn string. */
text?: string;
lines?: LayoutLine[];
/** `fixed` itself or under a fixed node (page chrome that repeats on every page). */
fixed?: boolean;
/** Authored `wrap={false}`. */
noWrap?: boolean;
/** Under a DECOR_ID node (or itself one). */
decor?: boolean;
/** position: absolute. */
abs?: boolean;
/** Text with an authored maxLines clamp (truncation is intended). */
maxLines?: number;
/** Text drawn from a render prop. */
render?: boolean;
/** letterSpacing in pt (the trailing step after the last glyph is not ink). */
letterSpacing?: number;
children: LayoutNode[];
}
export interface LayoutPage {
width: number;
height: number;
nodes: LayoutNode[];
}
/** The subset of react-pdf's internal node this module reads. */
interface RawNode {
type: string;
value?: string;
style?: Record<string, unknown>;
props?: Record<string, unknown>;
box?: Record<string, number | undefined>;
lines?: RawLine[];
children?: RawNode[];
}
interface RawLine {
string: string;
box: { x: number; y: number; width: number; height: number };
xAdvance: number;
height: number;
overflowLeft?: number;
overflowRight?: number;
}
const TYPES: Record<string, LayoutNodeType> = {
VIEW: "view",
TEXT: "text",
TSPAN: "text",
IMAGE: "image",
SVG: "svg",
LINK: "link",
};
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
function collectText(node: RawNode): string {
if (node.type === "TEXT_INSTANCE") return node.value ?? "";
return (node.children ?? []).map(collectText).join("");
}
function convert(
node: RawNode,
parent: { x: number; y: number },
parentDecor: boolean,
parentIsText: boolean,
parentFixed: boolean,
): LayoutNode | null {
if (node.type === "TEXT_INSTANCE") return null;
const b = node.box ?? {};
const x = parent.x + num(b.left);
const y = parent.y + num(b.top);
const type = TYPES[node.type] ?? "other";
// A Text nested in a Text is folded into the parent's attributed string and has no lines of its own.
if (type === "text" && parentIsText && !node.lines) return null;
const props = node.props ?? {};
const id = typeof props.id === "string" ? props.id : undefined;
const decor = parentDecor || id === DECOR_ID;
const out: LayoutNode = {
type,
...(id !== undefined ? { id } : {}),
box: { x, y, w: num(b.width), h: num(b.height) },
children: [],
};
// Everything under a fixed node is chrome too (it is the fixed node that repeats).
const fixed = parentFixed || props.fixed === true;
if (fixed) out.fixed = true;
if (props.wrap === false) out.noWrap = true;
if (decor) out.decor = true;
if (node.style?.position === "absolute") out.abs = true;
if (type === "text") {
const ls = num(node.style?.letterSpacing);
if (ls) out.letterSpacing = ls;
const maxLines = num(node.style?.maxLines);
if (maxLines) out.maxLines = maxLines;
const isRender = typeof props.render === "function";
if (isRender) out.render = true;
const padL = num(b.paddingLeft) + num(b.borderLeftWidth);
const padT = num(b.paddingTop) + num(b.borderTopWidth);
// `xAdvance` and `box.width` include the line's leading/trailing whitespace (`overflowLeft/Right`);
// the ink excludes it. Line 3 of a wrapped address can be 182.36 pt "wide" in a 180 pt box with
// overflowRight 2.36: that is just the trailing space.
out.lines = (node.lines ?? []).map((l) => ({
x: x + padL + num(l.box.x) + num(l.overflowLeft),
y: y + padT + num(l.box.y),
w: Math.max(0, num(l.xAdvance) - num(l.overflowLeft) - num(l.overflowRight)),
h: num(l.box.height) || num(l.height),
text: l.string,
}));
if (isRender) out.text = out.lines.map((l) => l.text).join(" ");
else {
const raw = collectText(node);
out.text = node.style?.textTransform === "uppercase" ? raw.toUpperCase() : raw;
}
}
// Svg internals have no boxes; keep the Svg a leaf.
if (type !== "svg") {
for (const child of node.children ?? []) {
const c = convert(child, { x, y }, decor, type === "text", fixed);
if (c) out.children.push(c);
}
}
return out;
}
/** Converts react-pdf's internal layout tree (the `_INTERNAL__LAYOUT__DATA_` of onRender). */
export function normalizeLayout(layoutData: unknown): { pages: LayoutPage[] } {
const doc = layoutData as RawNode | undefined;
if (!doc || !Array.isArray(doc.children)) throw new Error("normalizeLayout: not a react-pdf layout tree");
const pages: LayoutPage[] = doc.children
.filter((p) => p.type === "PAGE")
.map((p) => {
const b = p.box ?? {};
const nodes: LayoutNode[] = [];
for (const child of p.children ?? []) {
const c = convert(child, { x: 0, y: 0 }, false, false, false);
if (c) nodes.push(c);
}
return { width: num(b.width), height: num(b.height), nodes };
});
return { pages };
}
/** Depth-first walk of a page's nodes. `parent` is the enclosing node (undefined for top level). */
export function walk(nodes: LayoutNode[], visit: (n: LayoutNode, parent: LayoutNode | undefined) => void, parent?: LayoutNode): void {
for (const n of nodes) {
visit(n, parent);
walk(n.children, visit, n);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { chromeHeight } from "./metrics";
describe("chromeHeight", () => {
it("is ceil((ascent - descent + lineGap) / upm * size) + 1 from the manifest", () => {
// Plex: 1025 + 275 = 1300 / 1000 = 1.3 em
expect(chromeHeight({ face: "IBM Plex Sans", weight: 400, size: 8 })).toBe(Math.ceil(10.4) + 1);
expect(chromeHeight("footer")).toBe(Math.ceil(9.75) + 1);
expect(chromeHeight({ face: "IBM Plex Sans", weight: 400, size: 10 })).toBe(14);
});
it("covers the line react-pdf draws for render-prop text", () => {
// A 8 pt Plex line is 10.4 pt tall (verified in the layout tree); the reserve must be at least that.
expect(chromeHeight({ face: "IBM Plex Sans", weight: 400, size: 8 })).toBeGreaterThanOrEqual(10.4);
});
it("falls back across weights and styles instead of failing", () => {
expect(chromeHeight({ face: "IBM Plex Sans", weight: 600, italic: true, size: 8 })).toBe(12);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined {
const fam = FONT_MANIFEST.families.find((f) => f.family === family);
if (!fam) return undefined;
const style = italic ? "italic" : "normal";
return (
fam.fonts.find((f) => f.weight === weight && f.style === style) ??
fam.fonts.find((f) => f.weight === weight) ??
fam.fonts.find((f) => f.style === style) ??
fam.fonts[0]
);
}
/**
* Height in pt of one auto-height line of render-prop text (page numbers, the continuation band):
* `ceil((ascent - descent + lineGap) / upm * size) + 1`. Render-prop text has no lineHeight, so
* textkit uses the font's own metrics; a pinned box smaller than this draws nothing. The metrics are
* the primary face's: chrome text is Latin. A fallback glyph from a taller face would raise the line
* (see the Indic faces), so keep Indic text out of chrome or add headroom.
*/
export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly<Record<string, string>> } = {}): number {
const token = roleToken(role);
const family = resolveFace(token.face, opts.faceOverrides);
const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`);
// The manifest stores descent as a negative number, as the font's hhea table does.
const lineUnits = file.ascent - file.descent + file.lineGap;
return Math.ceil((lineUnits / file.upm) * token.size) + 1;
}
+75
View File
@@ -0,0 +1,75 @@
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { build } from "esbuild";
import { beforeAll, describe, expect, it } from "vitest";
/**
* react-pdf's paginate() is a synchronous loop with no progress guard, so a layout it cannot settle
* spins forever and an in-process timer never fires. These scenarios therefore run in a child process
* under a watchdog. scripts/hang/scenario.tsx is bundled with esbuild and run with plain node.
*/
const ROOT = process.cwd();
const OUT = path.join(ROOT, "node_modules/.cache/voiced-hang/scenario.mjs");
const WATCHDOG_MS = 15_000;
interface ChildResult {
killed: boolean;
stdout: string;
stderr: string;
}
function runScenario(scenario: string, rows: number): Promise<ChildResult> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [OUT, scenario, String(rows)], { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let killed = false;
child.stdout.on("data", (d) => (stdout += d));
child.stderr.on("data", (d) => (stderr += d));
const timer = setTimeout(() => {
killed = true;
child.kill("SIGKILL");
}, WATCHDOG_MS);
child.on("close", () => {
clearTimeout(timer);
resolve({ killed, stdout, stderr });
});
});
}
beforeAll(async () => {
mkdirSync(path.dirname(OUT), { recursive: true });
await build({
entryPoints: [path.join(ROOT, "scripts/hang/scenario.tsx")],
outfile: OUT,
bundle: true,
platform: "node",
format: "esm",
// Resolve react, react-pdf and fontkit from node_modules at run time, as the app does.
packages: "external",
jsx: "automatic",
logLevel: "error",
});
});
describe("paginate() hang hazard", () => {
it("page-level non-fixed absolute Svg hangs paginate (hazard pinned)", { timeout: 20_000 }, async () => {
const r = await runScenario("page-level-svg", 50);
// The child never finished: only the watchdog ended it. If this fails, react-pdf stopped hanging
// here and the page-level-decor ban (layout rule 6) can be revisited.
expect(r.killed).toBe(true);
expect(r.stdout).toBe("");
});
it("FirstPageHeader decor does not hang with 8, 50 or 120 rows and keeps the Svg on page 1 only", { timeout: 20_000 }, async () => {
const runs = await Promise.all([8, 50, 120].map((rows) => runScenario("first-page-header", rows)));
for (const r of runs) {
expect(r.killed, r.stderr).toBe(false);
expect(r.stderr).toBe("");
}
const out = runs.map((r) => JSON.parse(r.stdout) as { pages: number; svgPages: number[] });
expect(out.map((o) => o.pages)).toEqual([1, 2, 3]);
for (const o of out) expect(o.svgPages).toEqual(o.svgPages.map((_, i) => (i === 0 ? 1 : 0)));
});
});
+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));
});
});
});
+126
View File
@@ -0,0 +1,126 @@
import { createElement as h, type ReactNode } from "react";
import { Document, Page, Text, View } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { canonicalLayout, sha256Hex, sha256HexJs } from "../engine/fingerprint";
import { normalizeLayout } from "../engine/layoutTree";
import { byId, doc, frameFor, PAD, txt } from "../testing/docs";
import { setupPdfTest } from "../testing/setup";
import { renderCore, type RenderBytes } from "./core";
setupPdfTest();
describe("renderCore", () => {
const simple = (text = "Hello") => doc(h(Text, { id: "t", style: txt() }, text));
it("returns bytes, page count, the normalised layout, issues and a fingerprint", async () => {
const r = await renderCore(simple(), { frame: frameFor() });
expect(Buffer.from(r.bytes.subarray(0, 5)).toString()).toBe("%PDF-");
expect(r.pages).toBe(1);
expect(r.layout).toHaveLength(1);
expect(r.layout[0]).toMatchObject({ width: 400, height: 300 });
expect(r.issues).toEqual([]);
expect(r.fingerprint).toMatch(/^[0-9a-f]{64}$/);
const t = byId(r.layout[0], "t")[0];
expect(t).toMatchObject({ type: "text", text: "Hello" });
expect(t.box).toMatchObject({ x: PAD, y: PAD });
expect(t.lines![0].text).toBe("Hello");
});
it("audits only when given a frame, and passes decorBoxes as keep-outs", async () => {
expect((await renderCore(simple())).issues).toEqual([]);
const r = await renderCore(simple(), { frame: frameFor(), decorBoxes: [{ x: 0, y: 0, w: 100, h: 60 }] });
expect(r.issues.map((i) => i.code)).toEqual(["keepout"]);
});
it("fingerprints the layout: stable across renders and metadata, different when the layout differs", async () => {
const a = await renderCore(simple());
const b = await renderCore(simple());
expect(b.fingerprint).toBe(a.fingerprint);
const meta = await renderCore(h(Document, { creator: "Other 9.9", producer: "Other", creationDate: new Date(0) }, h(Page, { size: { width: 400, height: 300 }, style: { padding: PAD } }, h(Text, { id: "t", style: txt() }, "Hello"))));
expect(meta.fingerprint).toBe(a.fingerprint);
expect(Buffer.from(meta.bytes).equals(Buffer.from(a.bytes))).toBe(false);
expect((await renderCore(simple("Hello!"))).fingerprint).not.toBe(a.fingerprint);
const shifted = doc(h(Text, { id: "t", style: txt({ marginLeft: 3 }) }, "Hello"));
expect((await renderCore(shifted)).fingerprint).not.toBe(a.fingerprint);
});
it("rounds boxes to 0.1 pt for the fingerprint", async () => {
const a = await renderCore(doc(h(Text, { style: txt({ marginLeft: 3.01 }) }, "Hello")));
const b = await renderCore(doc(h(Text, { style: txt({ marginLeft: 3.02 }) }, "Hello")));
expect(b.fingerprint).toBe(a.fingerprint);
});
it("chains the root's own onRender and accepts a swapped bytes adapter", async () => {
let seen = false;
const el = h(Document, { onRender: () => (seen = true) }, h(Page, { size: { width: 400, height: 300 } }, h(Text, { style: txt() }, "x")));
const fake: RenderBytes = async (element) => {
// Mimic what the worker adapter does: drive react-pdf and hand back the bytes.
const { renderToBuffer } = await import("@react-pdf/renderer");
return new Uint8Array(await renderToBuffer(element as never));
};
const r = await renderCore(el, {}, fake);
expect(seen).toBe(true);
expect(r.pages).toBe(1);
});
it("fails clearly when the root never reaches onRender", async () => {
const NotForwarding = (): ReactNode => doc(h(Text, { style: txt() }, "x"));
await expect(renderCore(h(NotForwarding as never))).rejects.toThrow(/onRender never delivered/);
});
it("splits a long flow into pages and keeps fixed chrome flagged", async () => {
const el = doc([
...Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } })),
h(View, { key: "f", id: "foot", fixed: true, style: { position: "absolute", bottom: 4, left: PAD } }, h(Text, { id: "ft", style: txt() }, "Footer")),
]);
const r = await renderCore(el);
expect(r.pages).toBeGreaterThan(1);
for (const p of r.layout) {
expect(byId(p, "foot")[0].fixed).toBe(true);
expect(byId(p, "ft")[0].fixed).toBe(true);
}
});
});
describe("normalizeLayout", () => {
it("rejects something that is not a layout tree", () => {
expect(() => normalizeLayout(undefined)).toThrow();
expect(() => normalizeLayout({})).toThrow();
});
it("makes boxes absolute and drops the whitespace a line carries at its edges from the ink", async () => {
const el = doc(
h(View, { style: { padding: 4, marginTop: 10 } }, h(Text, { id: "t", style: txt({ width: 70 }) }, "Aurora Industrial Fasteners")),
);
const r = await renderCore(el);
const view = r.layout[0].nodes[0];
expect(view.box).toMatchObject({ x: PAD, y: PAD + 10 });
const t = view.children[0];
expect(t.box).toMatchObject({ x: PAD + 4, y: PAD + 10 + 4 });
// Every ink width fits the 70 pt box even though textkit's line.xAdvance counts the trailing space.
for (const l of t.lines!) expect(l.w).toBeLessThanOrEqual(70.01);
expect(t.lines!.map((l) => l.text).join("")).toBe("Aurora Industrial Fasteners");
});
it("folds nested Text into its parent", async () => {
const el = doc(h(Text, { id: "p", style: txt() }, "Total: ", h(Text, { style: txt({ fontWeight: 700 }) }, "1,000")));
const r = await renderCore(el);
const p = r.layout[0].nodes[0];
expect(p.children).toEqual([]);
expect(p.text).toBe("Total: 1,000");
});
it("canonicalLayout covers page size, text boxes with line strings and image boxes, not ids or styles", async () => {
const r = await renderCore(doc(h(Text, { id: "t", style: txt() }, "Hi")));
expect(JSON.parse(canonicalLayout({ pages: r.layout }))).toEqual([[400, 300, [["t", 20, 20, 360, 12, ["Hi"]]]]]);
});
});
describe("sha256", () => {
it("JS fallback equals crypto.subtle", async () => {
for (const s of ["", "abc", "a".repeat(55), "a".repeat(56), "a".repeat(64), "₹1,23,456.78 नमस्ते", "x".repeat(1000)]) {
expect(sha256HexJs(new TextEncoder().encode(s))).toBe(await sha256Hex(s));
}
expect(sha256HexJs(new TextEncoder().encode("abc"))).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { pdf, renderToBuffer } from "@react-pdf/renderer";
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 { normalizeLayout, type LayoutPage, type Rect } from "../engine/layoutTree";
export { normalizeLayout, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree";
export { auditLayout, type AuditIssue } from "../engine/audit";
export { layoutFingerprint } from "../engine/fingerprint";
export interface RenderCoreOptions {
/** With a frame the layout is audited; without one `issues` is empty. */
frame?: PageFrame;
/** Page-space boxes content must stay out of (audited as `keepout`). */
decorBoxes?: Rect[];
/** Further audit options (closing block id, ...). */
audit?: Omit<AuditOptions, "keepOuts">;
}
export interface RenderCoreResult {
bytes: Uint8Array;
pages: number;
layout: LayoutPage[];
issues: AuditIssue[];
/** sha256 of the normalised layout (not of the bytes; Creator/Producer feed the PDF /ID). */
fingerprint: string;
}
/** Turns an element into PDF bytes. Swappable: the render worker supplies its own. */
export type RenderBytes = (element: ReactElement) => Promise<Uint8Array>;
const isNode = typeof process !== "undefined" && !!process.versions?.node;
/**
* Node uses renderToBuffer; the browser build of react-pdf throws from it, so there (and in a worker)
* pdf().toBlob() is used. Both call the Document's onRender with the layout tree.
*/
export const renderBytes: RenderBytes = async (element) => {
const el = element as Parameters<typeof renderToBuffer>[0];
if (isNode) return new Uint8Array(await renderToBuffer(el));
const blob = await pdf(el).toBlob();
return new Uint8Array(await blob.arrayBuffer());
};
/**
* Renders `element` once and returns the bytes, the normalised layout tree, the audit issues and the
* layout fingerprint.
*
* The layout tree comes from the Document's `onRender` (`_INTERNAL__LAYOUT__DATA_`). For now the root
* element is cloned with an `onRender` added (chained after the one it already has). The root must
* therefore be a `<Document>` element, or a component that forwards `onRender` to its `<Document>`
* (InvoicePdf will).
*/
export async function renderCore(
element: ReactElement,
opts: RenderCoreOptions = {},
bytesOf: RenderBytes = renderBytes,
): Promise<RenderCoreResult> {
let raw: unknown;
const existing = (element.props as { onRender?: (p: unknown) => void }).onRender;
const wrapped = cloneElement(element as ReactElement<{ onRender?: (p: unknown) => void }>, {
onRender: (params: unknown) => {
raw = (params as { _INTERNAL__LAYOUT__DATA_?: unknown } | undefined)?._INTERNAL__LAYOUT__DATA_;
existing?.(params);
},
});
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");
}
const { pages } = normalizeLayout(raw);
const issues = opts.frame ? auditLayout({ pages }, opts.frame, { ...opts.audit, keepOuts: opts.decorBoxes }) : [];
return { bytes, pages: pages.length, layout: pages, issues, fingerprint: await layoutFingerprint({ pages }) };
}
+54
View File
@@ -0,0 +1,54 @@
import { cloneElement, createElement as h, type ReactElement, type ReactNode } from "react";
import { Document, Page, renderToBuffer } from "@react-pdf/renderer";
import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
import { walk, type LayoutNode, type LayoutPage } from "../engine/layoutTree";
import { stack } from "../fonts/register";
/** Small fixtures for tests that need a tiny react-pdf document. */
export const FAMILY = stack("IBM Plex Sans");
export const PAGE_W = 400;
export const PAD = 20;
export const txt = (extra: Record<string, unknown> = {}) => ({ fontFamily: FAMILY, fontSize: 10, lineHeight: "12pt", ...extra });
export function doc(children: ReactNode, o: { height?: number; width?: number; pad?: number | Record<string, number> } = {}): ReactElement {
const pad = o.pad ?? PAD;
return h(
Document,
null,
h(
Page,
{ size: { width: o.width ?? PAGE_W, height: o.height ?? 300 }, style: typeof pad === "number" ? { padding: pad } : pad },
children,
),
);
}
/** A frame matching `doc()`: uniform padding `PAD`, optional footer reserve inside the bottom padding. */
export function frameFor(o: { height?: number; width?: number; footerReserve?: number; continuationReserve?: number } = {}): PageFrame {
return computeFrame({
page: { ...PAGE_SIZES.A4, width: o.width ?? PAGE_W, height: o.height ?? 300 },
margins: { top: PAD, right: PAD, bottom: PAD, left: PAD },
footerReserve: o.footerReserve ?? 0,
continuationReserve: o.continuationReserve ?? 0,
letterMode: false,
});
}
export function find(page: LayoutPage, pred: (n: LayoutNode) => boolean): LayoutNode[] {
const out: LayoutNode[] = [];
walk(page.nodes, (n) => {
if (pred(n)) out.push(n);
});
return out;
}
export const byId = (page: LayoutPage, id: string) => find(page, (n) => n.id === id);
/** react-pdf's own layout tree, for facts the normalised tree hides (style values, raw props). */
export async function rawLayout(el: ReactElement): Promise<{ type: string; children: any[] }> {
let raw: any;
const withHook = cloneElement(el as ReactElement<any>, { onRender: (p: any) => (raw = p._INTERNAL__LAYOUT__DATA_) });
await renderToBuffer(withHook as never);
return raw;
}
+2 -1
View File
@@ -9,7 +9,8 @@ export interface LintViolation {
/** Paths (relative to the repo root, posix) where each otherwise-banned construct may appear. */
export const LINT_ALLOW = {
leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts"],
// testing/docs.ts builds throwaway fixture Text styles for tests, not template code.
leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts", "src/pdf/testing/docs.ts"],
absolute: ["src/pdf/decor/", "src/pdf/blocks/PageChrome.tsx", "src/pdf/blocks/FirstPageHeader.tsx"],
rawFlags: ["src/pdf/blocks/pdfProps.ts"],
};
+40
View File
@@ -0,0 +1,40 @@
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
/** `@napi-rs/canvas` is an optional dependency; null when its native binary is not installed. */
export async function loadCanvas(): Promise<typeof import("@napi-rs/canvas") | null> {
try {
return await import("@napi-rs/canvas");
} catch {
return null;
}
}
/** Number of non-white pixels in the box (x0,y0,x1,y1 in pt from the top-left) of page 1 drawn at `scale`. */
export async function inkPixels(
canvasLib: typeof import("@napi-rs/canvas"),
bytes: Uint8Array,
box: { x0: number; y0: number; x1: number; y1: number },
scale = 2,
): Promise<number> {
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
const doc = await task.promise;
try {
const page = await doc.getPage(1);
const viewport = page.getViewport({ scale });
const canvas = canvasLib.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx as never, canvas: canvas as never, viewport }).promise;
const x = Math.floor(box.x0 * scale);
const y = Math.floor(box.y0 * scale);
const w = Math.ceil((box.x1 - box.x0) * scale);
const h = Math.ceil((box.y1 - box.y0) * scale);
const data = ctx.getImageData(x, y, w, h).data;
let ink = 0;
for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++;
return ink;
} finally {
await task.destroy();
}
}