Add the Neutral template family, template picker and text-only logo allowances

Linea (the vendor's original format.pdf design), Monogram and Serenity are
rebuilt from the Canva PDFs as one parametric family on the engine, with
the user's logo in the measured slot, a home for every GST field and
A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and
a generated Ms Madi outline for Serenity's vertical word. Text-only logo
placeholders (Monolith, Tangerine) use a doc-derived allowance box. A
Carbon TemplatePicker with committed thumbnails opens from the page
setup controls.
This commit is contained in:
2026-10-04 07:27:12 +05:30
parent 86aaa1a790
commit 04d92aa149
61 changed files with 3278 additions and 110 deletions
+40 -15
View File
@@ -10,10 +10,14 @@ import { pdfFlags } from "./pdfProps";
import { Cell, Row } from "./Row";
import { Txt } from "./Txt";
/** A cell: one string, or several (one Txt each, stacked), for multi-line values. */
export type TableCellValue = string | string[];
/** 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): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
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;
@@ -30,6 +34,8 @@ export interface TableLook {
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
padRight?: 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 {
@@ -57,12 +63,14 @@ export interface ItemsTablePlan {
}
function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, 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");
let lines = 0;
for (const text of cellParts(value)) {
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");
lines += Math.max(
const n = Math.max(
1,
m.wrapLines(drawnText(t, text), inner, {
face: resolveFace(t.face, look.faceOverrides),
@@ -72,8 +80,10 @@ function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellVal
letterSpacing: (t.tracking ?? 0) * t.size,
}).length,
);
lines += n;
height += n * t.leading;
}
return { lines, height: lines * t.leading };
return { lines, height };
}
/**
@@ -100,7 +110,7 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
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 headH = heightOf(roles.head, header).height + (input.headerTailH ?? 0);
const measured = rows.map((r) => heightOf(roles.body, r));
const rowHeights = measured.map((r) => r.height);
@@ -134,6 +144,12 @@ export interface TableRowsProps extends TableLook {
borderColor?: string;
/** Text colour of the header cells. */
headColor?: string;
/**
* 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;
}
/**
@@ -141,7 +157,7 @@ export interface TableRowsProps extends TableLook {
* `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, ...look }: TableRowsProps) {
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, headerTail, ...look }: TableRowsProps) {
const padX = look.padX ?? 0;
const padY = look.padY ?? 4;
const border = look.border ?? 0;
@@ -154,8 +170,8 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
values.map((v, i) => (
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
{cellParts(v).map((part, li) => (
<Txt key={li} role={role} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
{part}
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
{partText(part)}
</Txt>
))}
</Cell>
@@ -164,9 +180,18 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
return (
<AvailableWidthProvider width={width}>
<View>
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
{cells(roles.head, header, headColor)}
</Row>
{headerTail ? (
<View {...pdfFlags({ fixed: true })}>
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} style={cellStyle(headerStyle)}>
{cells(roles.head, header, headColor)}
</Row>
{headerTail}
</View>
) : (
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
{cells(roles.head, header, headColor)}
</Row>
)}
{rows.map((r, i) => (
<Row
key={i}