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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user