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
+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([]);
});
});