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.
300 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|