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>; } 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; rowStyle?: Record; 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) => ({ ...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}), ...extra, }); const cells = (role: RoleName | TypeToken, values: string[]) => values.map((v, i) => ( {v} )); return ( {cells(roles.head, header)} {rows.map((r, i) => ( {cells(roles.body, r)} ))} {tail} ); }