Files
Voiced/src/pdf/blocks/ItemsTable.tsx
T
xavierk 385d7974cb Add the Monolith and Cobalt Stripe templates
Standalone layouts for Canva templates 3 and 8, with the user's logo sized to the
placeholder box, a CapRule decor primitive, optional TableRows rule/heading options,
and a separate 1..45-row sweep test file.
2026-10-04 11:25:19 +05:30

300 lines
14 KiB
TypeScript

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<Record<string, string>>;
/** 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<string, unknown>;
/** Style of each body row; a function gets the row index (alternating fills). */
rowStyle?: Record<string, unknown> | ((index: number) => Record<string, unknown>);
/** 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<string, unknown> | 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<string, unknown>, 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 (
<Cell
key={i}
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX + (look.padLeft?.[i] ?? 0), right: padX + (look.padRight?.[i] ?? 0) }}
borderLeft={grid && i === 0 ? edge : 0}
borderRight={grid ? edge : inner2}
style={{
...(innerRules ? { flexGrow: 1, flexBasis: "auto", ...(inner2 ? { borderRightColor: isHeader ? (innerRules.headerColor ?? innerRules.color) : innerRules.color } : {}) } : {}),
...(grid
? {
flexGrow: 1,
flexBasis: "auto",
borderLeftColor: outer,
borderRightColor: last ? outer : inner,
...(isHeader ? { borderTopWidth: edge, borderTopColor: outer } : {}),
}
: {}),
...(isHeader ? headCellStyle?.(i) : {}),
}}
>
{cellParts(v).map((part, li) => (
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - (look.padLeft?.[i] ?? 0) - ruleW)} align={alignOf} faceOverrides={look.faceOverrides}>
{partText(part)}
</Txt>
))}
</Cell>
);
});
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<typeof pdfFlags>, style: Record<string, unknown>, children: ReactNode) =>
pill ? (
<View key={key} id={id} {...flags} style={{ ...pinned(width), marginLeft: pill.indent, paddingLeft: inset, paddingRight: inset, ...style } as never}>
<Row widths={columns} gap={gap} width={cellsWidth} align={rowAlign}>
{children}
</Row>
</View>
) : (
<Row key={key} id={id} widths={columns} gap={gap} width={width} align={rowAlign} {...flags} style={style}>
{children}
</Row>
);
return (
<AvailableWidthProvider width={width}>
<View>
{headerTail || bleed ? (
<View
{...pdfFlags({ fixed: true })}
style={bleed ? { marginLeft: -bleed.left, width: bleed.width, minWidth: bleed.width, maxWidth: bleed.width, paddingLeft: bleed.left, backgroundColor: bleed.bg } : undefined}
>
{line("head", TABLE_HEAD_ID, {}, cellStyle(headerStyle, headBorder), cells(roles.head, header, headColor, true))}
{headerTail}
</View>
) : (
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}
</View>
</AvailableWidthProvider>
);
}