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.
174 lines
6.8 KiB
TypeScript
174 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|