import { View } from "@react-pdf/renderer"; import type { ReactNode } from "react"; import { colsDetailed, pinned, 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"; /** One line of a cell: text in the table's role, or in a role of its own (a small sub-line under a description). */ export type CellPart = string | { text: string; role: RoleName | TypeToken }; /** A cell: one string, or several parts (one Txt each, stacked), for multi-line values. */ export type TableCellValue = string | CellPart[]; const cellParts = (v: TableCellValue): CellPart[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]); const partText = (p: CellPart) => (typeof p === "string" ? p : p.text); const partRole = (p: CellPart, role: RoleName | TypeToken) => (typeof p === "string" ? role : p.role); 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; /** Vertical padding of the header row; default `padY`. */ headPadY?: number; /** Bottom border of each row in pt (counted in the row height). Default 0. */ border?: number; /** Bottom border of the header row; default `border`. */ headBorder?: number; /** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */ padRight?: number[]; /** Extra left padding per column, pt (a header or body text that starts inside a filled cell). Counted in widths. */ padLeft?: number[]; faceOverrides?: Readonly>; /** Height of the `headerTail` TableRows draws under the header row (a rule and its spacing); counted in the header height. */ headerTailH?: number; } 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: TableCellValue[]; rows: TableCellValue[][]; 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, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } { let lines = 0; let height = 0; for (const part of cellParts(value)) { const t = roleToken(partRole(part, role)); if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan"); const text = partText(part); if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan"); const n = 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, ); lines += n; height += n * t.leading; } return { lines, height }; } /** * 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, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0) - (input.padLeft?.[i] ?? 0))); const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[], py = padY, bd = border) => { 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 * py + bd }; }; const headH = heightOf(roles.head, header, input.headPadY ?? padY, input.headBorder ?? border).height + (input.headerTailH ?? 0); 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: TableCellValue[]; rows: TableCellValue[][]; roles: TableRoles; /** Per column; default left. */ align?: Array<"left" | "right" | "center">; /** Header alignment per column when it differs from the body's; default `align`. */ headAlign?: 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; /** Style of each body row; a function gets the row index (alternating fills). */ rowStyle?: Record | ((index: number) => Record); /** Style of one header cell by column (a filled cell beside an unfilled one). The cell is as tall as the header row only while the header is one line. */ headCellStyle?: (column: number) => Record | undefined; borderColor?: string; /** Text colour of the header cells, or per column. */ headColor?: string | ((column: number) => string | undefined); /** * Drawn under the header row inside the same fixed group, so it repeats with the header on every page * (a rule and the gap below it: nothing under a repeated header may depend on a first-page-only margin). * Pass the same height as `headerTailH` to planItemsTable. */ headerTail?: ReactNode; /** * Ruled grid: a vertical rule after every column, one on the outer left edge, and a rule above the * header cells (each row draws its own bottom rule through `border`). The cells stretch to the row's * height so the verticals run the full row. In the header the inner verticals take `headerColor` and * the outer edges (top, left, right) `headerEdge`; both default to `color`. Plan with `gridPadRight(...)` so wrapping uses the real * inner width, and add `grid.width` to `headerTailH` for the header's top rule. */ grid?: { width: number; color: string; headerColor?: string; headerEdge?: string }; /** * Header band that bleeds to the page edges: `left` is the page-edge-to-table distance, `width` the page * width. The header group is fixed (it repeats), so the bleed passes the safe-zone audit. */ bleed?: { left: number; width: number; bg: string }; /** * Rounded "pill" rows (Purple Pop): `width` is the pill's outer width, the cells are laid out `rowInset` inside * it on both sides, and the pill itself (rowStyle / headerStyle carry its fill and radius) moves `indent` right * of the container's left edge. Plan with planItemsTable at width - 2 * rowInset. */ pill?: { indent: number; inset: number }; /** * Vertical rules between the columns only (no outer edges, no rule above the header): `width` after every column but * the last, in `color` on body rows and `headerColor` in the header (default `color`). The cells stretch to the row's * height. Plan with `innerRulePadRight(...)` added to the padding so wrapping uses the real inner width. */ innerRules?: { width: number; color: string; headerColor?: string }; } /** Extra right padding per column that stands in for a grid's vertical rules, for planItemsTable's `padRight`. */ export function gridPadRight(columns: number, ruleWidth: number): number[] { return Array.from({ length: columns }, (_, i) => (i === 0 ? 2 : 1) * ruleWidth); } /** Extra right padding per column that stands in for the inner rules of `innerRules`, for planItemsTable's `padRight`. */ export function innerRulePadRight(columns: number, ruleWidth: number): number[] { return Array.from({ length: columns }, (_, i) => (i < columns - 1 ? ruleWidth : 0)); } /** * 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, headCellStyle, borderColor, headColor, headerTail, grid, bleed, pill, innerRules, headAlign, ...look }: TableRowsProps) { const padX = look.padX ?? 0; const padY = look.padY ?? 4; const headPadY = look.headPadY ?? padY; const border = look.border ?? 0; const headBorder = look.headBorder ?? border; const { widths } = colsDetailed(width - 2 * (pill?.inset ?? 0), columns, gap); const cellStyle = (extra?: Record, bd = border) => ({ ...(bd ? { borderBottomWidth: bd, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}), ...extra, }); const cells = (role: RoleName | TypeToken, values: TableCellValue[], colorOf?: string | ((column: number) => string | undefined), isHeader = false) => values.map((v, i) => { const color = typeof colorOf === "function" ? colorOf(i) : colorOf; const edge = grid?.width ?? 0; const last = i === values.length - 1; const inner = isHeader ? (grid?.headerColor ?? grid?.color) : grid?.color; const outer = isHeader ? (grid?.headerEdge ?? grid?.color) : grid?.color; const inner2 = innerRules && !last ? innerRules.width : 0; const ruleW = grid ? edge * (i === 0 ? 2 : 1) : inner2; const alignOf = isHeader ? (headAlign?.[i] ?? align[i]) : align[i]; return ( {cellParts(v).map((part, li) => ( {partText(part)} ))} ); }); const rowAlign = grid || innerRules ? ("stretch" as const) : undefined; const inset = pill?.inset ?? 0; const cellsWidth = width - 2 * inset; /** One table line: a plain Row, or for pills an outer box (the fill) around a Row of the inner width. */ const line = (key: string | number, id: string, flags: ReturnType, style: Record, children: ReactNode) => pill ? ( {children} ) : ( {children} ); return ( {headerTail || bleed ? ( {line("head", TABLE_HEAD_ID, {}, cellStyle(headerStyle, headBorder), cells(roles.head, header, headColor, true))} {headerTail} ) : ( line("head", TABLE_HEAD_ID, pdfFlags({ fixed: true }), cellStyle(headerStyle, headBorder), cells(roles.head, header, headColor, true)) )} {rows.map((r, i) => line( i, `${TABLE_ROW_PREFIX}${i}`, pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined }), cellStyle(typeof rowStyle === "function" ? rowStyle(i) : rowStyle), cells(roles.body, r), ), )} {tail} ); }