Files
Voiced/src/pdf/blocks/ItemsTable.tsx
T
xavierk e2032a3f9c 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.
2026-10-04 05:05:51 +05:30

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>
);
}