Add the Northline template family: Purple Pop and Citrus Split

Purple Pop (cream page, purple pills, asterisk and dome decor, opt-in
logo) and Citrus Split (grey split panel, halftone chevrons, icon
contacts, logo in the measured slot) are rebuilt from the Canva PDFs on
the engine with A4/Letter support, GST slots and zero audit errors. Adds
static DM Sans, Jost and Poppins 800 as look-alikes for the commercial
fonts. The picker now lists ten templates.
This commit is contained in:
2026-10-04 09:56:56 +05:30
parent 06976df55c
commit 5ea62a9be8
36 changed files with 3033 additions and 56 deletions
+53 -34
View File
@@ -1,6 +1,6 @@
import { View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { colsDetailed, type ColSpec } from "../engine/columns";
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";
@@ -37,6 +37,8 @@ export interface TableLook {
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;
@@ -108,7 +110,7 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
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)));
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 };
@@ -144,10 +146,13 @@ export interface TableRowsProps extends TableLook {
/** Content after the last row, inside the table container (the totals). */
tail?: ReactNode;
headerStyle?: Record<string, unknown>;
rowStyle?: 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. */
headColor?: 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).
@@ -167,6 +172,12 @@ export interface TableRowsProps extends TableLook {
* 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 };
}
/** Extra right padding per column that stands in for a grid's vertical rules, for planItemsTable's `padRight`. */
@@ -179,19 +190,20 @@ export function gridPadRight(columns: number, ruleWidth: number): number[] {
* `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, headColor, headerTail, grid, bleed, ...look }: TableRowsProps) {
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, headCellStyle, borderColor, headColor, headerTail, grid, bleed, pill, ...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, columns, gap);
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[], color?: string, isHeader = false) =>
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;
@@ -200,11 +212,11 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
return (
<Cell
key={i}
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}
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={edge}
style={
grid
style={{
...(grid
? {
flexGrow: 1,
flexBasis: "auto",
@@ -212,11 +224,12 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
borderRightColor: last ? outer : inner,
...(isHeader ? { borderTopWidth: edge, borderTopColor: outer } : {}),
}
: undefined
}
: {}),
...(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) - ruleW)} align={align[i]} faceOverrides={look.faceOverrides}>
<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={align[i]} faceOverrides={look.faceOverrides}>
{partText(part)}
</Txt>
))}
@@ -224,6 +237,21 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
);
});
const rowAlign = grid ? ("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}>
@@ -233,30 +261,21 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
{...pdfFlags({ fixed: true })}
style={bleed ? { marginLeft: -bleed.left, width: bleed.width, minWidth: bleed.width, maxWidth: bleed.width, paddingLeft: bleed.left, backgroundColor: bleed.bg } : undefined}
>
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} style={cellStyle(headerStyle, headBorder)}>
{cells(roles.head, header, headColor, true)}
</Row>
{line("head", TABLE_HEAD_ID, {}, cellStyle(headerStyle, headBorder), cells(roles.head, header, headColor, true))}
{headerTail}
</View>
) : (
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} fixed style={cellStyle(headerStyle, headBorder)}>
{cells(roles.head, header, headColor, true)}
</Row>
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),
),
)}
{rows.map((r, i) => (
<Row
key={i}
id={`${TABLE_ROW_PREFIX}${i}`}
widths={columns}
gap={gap}
width={width}
align={rowAlign}
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
style={cellStyle(rowStyle)}
>
{cells(roles.body, r)}
</Row>
))}
{tail}
</View>
</AvailableWidthProvider>