Add the Marble and Highlighter templates, completing the 13 Canva templates

Marble uses a flat-to-gradient band in place of the marble photo and bundles Lustria (OFL).
Both logo slots are opt-in. Quirk 13 documents a fontkit glyph-cache issue that the
tests work around with warm-up renders; the engine fix follows separately.
This commit is contained in:
2026-10-04 12:47:05 +05:30
parent 3d42f383ca
commit 579508742f
32 changed files with 3190 additions and 27 deletions
+277
View File
@@ -0,0 +1,277 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import type { RenderModel } from "../../model/build";
import type { TypeToken } from "../../fonts/roles";
import type { PlacedLogo } from "../contract";
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
import { leading, type MarbleVariant } from "./tokens";
export const NOTES_FLOW_GAP = 14;
const FIT_AIR = 2;
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
export const REVERSE_GAP = 4;
/** Space between the label of a words / notes block and its text. */
export const LABEL_GAP = 3;
/** Right gutter of a parties column: the text never reaches the next column. */
export const PARTY_GUTTER = 12;
/** The GST-free sample's due line when the invoice has no due date. */
/** Widest the rate column grows to keep the unit on the rate's own line. */
const RATE_MAX = 112;
export const ON_RECEIPT = "On Receipt";
/** Tracking after the last glyph: right-aligned tracked text is drawn this far into the margin to end on the edge. */
export const stepOf = (t: TypeToken) => (t.tracking ?? 0) * t.size;
/** "Tax" rows read "CGST 9%" like the rest of the invoice's labels. */
export const taxLabel = (label: string, rate: string | undefined) => (rate ? `${label} ${rate}` : label);
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
function idLines(role: TypeToken, text: string, width: number): number {
const f = fitSize(role, text, Math.max(1, width));
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)));
}
export interface StackLine {
kind: "cancelled" | "number" | "issued" | "due";
text: string;
}
/** The right-hand stack under the title: the number on its own line (the title stays short), then the design's Issued and Due. */
export function stackLines(model: RenderModel): StackLine[] {
const out: StackLine[] = [];
if (model.cancelled) out.push({ kind: "cancelled", text: "CANCELLED" });
out.push({ kind: "number", text: `${model.meta.numberLabel} ${model.meta.number}` });
out.push({ kind: "issued", text: `Issued: ${model.meta.date}` });
out.push({ kind: "due", text: `Due: ${model.meta.dueDate ?? ON_RECEIPT}` });
return out;
}
export function signatureOptions(v: MarbleVariant): SignatoryOptions {
const r = v.roles;
return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.rule, color: v.ink }, color: v.ink, imageHeight: v.closing.signatureImageH };
}
/** The table's description (+ HSN/SAC sub-line), rate, qty and amount cells. */
export function tableRows(model: RenderModel, v: MarbleVariant, rateW: number): TableCellValue[][] {
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
});
}
/** The payable-to column: the account holder, then "label: value" per bank field. */
export function payableLines(model: RenderModel): string[] {
// "A/c No." keeps an 11-digit account number on its label's line in the 160 pt column.
return model.bank ? [model.bank.accountName, ...model.bank.lines.map((l) => `${l.label === "Account No." ? "A/c No." : l.label}: ${l.value}`)] : [];
}
export interface MarblePlan {
v: MarbleVariant;
W: number;
head: {
titleSize: number;
stackW: number;
lines: StackLine[];
leftW: number;
showName: boolean;
nameH: number;
/** Where the logo sits inside the 50 pt slot box, and the name below it. */
logoTop: number;
leftH: number;
stackH: number;
h: number;
};
info: { cols: number[]; hasBank: boolean; hasIds: boolean; clientH: number; bankH: number; idsH: number; h: number };
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
cw: { qty: number; rate: number; amount: number };
table: ItemsTablePlan;
guardH: number;
tail: { boxW: number; labelW: number; valueW: number; leftW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackH: number; dueText: string; dueSize: number; wordsH: number; reverseH: number; leftH: number; body: number };
closing: { notes: string[]; notesBeside: boolean; notesH: number; flowH: number; signH: number; notesW: number; bandH: number; h: number };
gaps: { headRule: number; infoTop: number; infoBottom: number; band: number; tail: number; close: number };
tailH: number;
closingH: number;
}
/**
* Everything the layout must know before rendering: the right-hand stack (the title fitted to it), the parties
* columns, the table columns, the totals stack and the heights of the tail and the closing block (their
* minPresenceAhead values), each a hair high rather than low.
*/
export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVariant, logo: PlacedLogo | null, includesName: boolean): MarblePlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const s = model.supplier;
const c = model.client;
const hd = v.head;
// --- header: the vendor (or its logo) at the left, the title and the number / issued / due lines at the right -------------
const lines = stackLines(model);
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(m, role, text)) + 1;
const natural = Math.max(widthOf(r.title, model.docTitle), ...lines.map((l) => widthOf(r.meta, l.text)));
const stackW = Math.min(Math.max(hd.minStackW, natural), Math.floor(W * hd.maxShare));
const titleFit = fitSize(r.title, model.docTitle, stackW + stepOf(r.title));
let linesH = 0;
for (const l of lines) linesH += idLines(r.meta, l.text, stackW + stepOf(r.meta)) * leading(r.meta);
const stackH = hd.titleTop + leading(r.title) + hd.titleGap + linesH;
const leftW = Math.max(hd.minNameW, W - stackW - hd.colGap - v.inset.left);
const showName = !(logo && includesName) && s.name.trim() !== "";
const nameH = showName ? textHeight(r.brand, s.name, leftW) : 0;
const logoBox = logo ? hd.logoH : 0;
const logoTop = logo ? Math.max(0, logo.slotOffset?.y ?? hd.logoH - logo.h) : 0;
const leftH = logo ? Math.max(logoBox, logoTop + logo.h) + (showName ? hd.nameGap + nameH : 0) : showName ? hd.nameTop + nameH : 0;
const headH = Math.max(stackH, leftH);
// --- parties band: BILL TO, PAYABLE TO (bank) and the supplier's identifiers, in the design's three columns -----------------
const gstinLines = slotHeights.gstinLines(model, "supplier");
const hasIds = gstinLines + (s.pan ? 1 : 0) > 0;
const payable = payableLines(model);
const hasBank = payable.length > 0;
const colW = [v.parties.colA, v.parties.colB, W - v.inset.left - v.inset.right - v.parties.colA - v.parties.colB];
const present = [true, hasBank, hasIds];
const widths = colW.filter((_, i) => present[i]);
const textW = (i: number) => Math.max(40, widths[i] - PARTY_GUTTER);
let k = 0;
const clientW = textW(k++);
const bankW = hasBank ? textW(k++) : 0;
const idsW = hasIds ? textW(k++) : 0;
const head = leading(r.heading) + v.parties.headingGap;
let clientH = head + textHeight(r.body, c.name, clientW);
for (const l of c.addressLines) clientH += textHeight(r.body, l, clientW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, clientW) * leading(r.body);
if (c.poNumber) clientH += textHeight(r.body, `PO: ${c.poNumber}`, clientW);
const bankH = hasBank ? head + payable.reduce((h, x) => h + textHeight(r.body, x, bankW), 0) : 0;
const idsH = hasIds ? head + (gstinLines ? idLines(r.body, GSTIN_SAMPLE, idsW) * leading(r.body) : 0) + (s.pan ? idLines(r.body, `PAN: ${s.pan}`, idsW) * leading(r.body) : 0) : 0;
const infoH = Math.max(clientH, bankH, idsH);
// --- table -----------------------------------------------------------------------------------------------------------
const t = v.table;
const need = (texts: string[], head: string, pad: number, headStep: number) =>
Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
// The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour".
const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—";
});
const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["Description", "Qty", "Rate", "Total"];
const align: MarblePlan["align"] = ["left", "center", "right", "right"];
const padLeft = [t.descInset, 0, 0, 0];
// The tracking after a right-aligned cell's last glyph is part of its width: take it off the padding so the ink ends where the design puts it.
const bodyStep = stepOf(r.tableBody);
const padRight = [8, 0, t.ratePad - bodyStep, t.numPad - bodyStep];
const rows = tableRows(model, v, cw.rate - t.ratePad - 2);
const n = rows.length;
const planTable = (padY: number, tailH: number) =>
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY: 0, border: 0, headBorder: 0, headerTailH: t.headGap, tailH, contentH: frame.contentHeight });
// --- totals: the stack (small caps labels, values at the right), then AMOUNT DUE; words and reverse charge at the left ----------
const tt = model.totals;
const o = v.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: tt.subtotalText }];
if (tt.discountText) stack.push({ label: "Discount", amount: `- ${tt.discountText}` });
if (tt.taxableText) stack.push({ label: "Taxable value", amount: tt.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const taxTexts = slotHeights.taxRowTexts(model, taxLabel);
const labels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)];
const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)];
const labelW = Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)))) + 16;
const valueW = Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)))) + 4;
const dueText = `Amount due: ${tt.totalText}`;
const dueFit = fitSize(r.due, dueText, 10000);
const dueW = Math.ceil(dueFit.width) + 4;
const boxW = Math.max(o.boxW, labelW + valueW, dueW);
const leftW0 = Math.max(0, W - o.rightInset - boxW - o.leftGap - v.inset.left);
const stackRowsH = (stack.length + slotRows) * leading(r.totalLabel) + o.rowGap * (stack.length + slotRows);
const stackH2 = stackRowsH + o.dueGap + leading(r.due);
const wordsW = Math.min(o.wordsMax, leftW0);
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.small, labelRole: r.smallLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const wordsBlockH = wordsH || reverseH ? wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0;
const body = Math.max(stackH2, wordsBlockH);
// --- closing: the notes (when short) beside the signature, above the foot band ---------------------------------------------
const cl = v.closing;
const notes = model.notesLines;
const notesW = Math.max(100, W - cl.signW - cl.gap - v.inset.left);
const notesH = notes.length ? leading(r.smallLabel) + LABEL_GAP + notes.reduce((h, l) => h + textHeight(r.small, l, notesW), 0) : 0;
const notesBeside = notes.length === 0 || notesH <= cl.notesBesideMax;
const flowH = notesBeside ? 0 : notesH;
const signH = signatoryHeight(model, cl.signW, signatureOptions(v));
const bandH = Math.max(notesBeside ? notesH : 0, signH);
const cancelledH = model.cancelled ? 10 + textHeight(r.small, CANCELLED_SENTENCE, W) : 0;
const closingFor = (g: { close: number }) => g.close + bandH + cancelledH;
// --- gaps: the empty band under the hairlines and the row padding give back Letter's missing height --------------------
const padYn = t.padY(n);
const ranges = { ...v.gaps, rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n } };
const tailNominal = ranges.tail.nominal + body;
const closingNominal = closingFor({ close: ranges.close.nominal });
const table0 = planTable(padYn, 0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalTotal = headH + ranges.headRule.nominal + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailNominal + notesBlock + closingNominal;
const slackBefore = ranges.headRule.nominal - ranges.headRule.min + ranges.infoTop.nominal - ranges.infoTop.min + ranges.infoBottom.nominal - ranges.infoBottom.min + ranges.band.nominal - ranges.band.min + ranges.tail.nominal - ranges.tail.min + ranges.rowPad.nominal - ranges.rowPad.min;
const slack = slackBefore + ranges.close.nominal - ranges.close.min;
const overflow = nominalTotal - frame.contentHeight;
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
// page 1 and let only the closing block move to the next page (its own gap, which cannot help page 1, stays nominal).
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
const { close: closeRange, ...beforeRanges } = ranges;
let g: Record<"headRule" | "infoTop" | "infoBottom" | "band" | "tail" | "rowPad" | "close", number>;
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), close: closeRange.nominal };
else g = elastic(ranges, frame.heightDeficit);
const padY = n ? g.rowPad / (2 * n) : padYn;
const tailH = g.tail + body;
const closingH = closingFor({ close: g.close });
const table = planTable(padY, tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailH : 0);
return {
v,
W,
head: { titleSize: titleFit.size, stackW, lines, leftW, showName, nameH, logoTop, leftH, stackH, h: headH },
info: { cols: widths, hasBank, hasIds, clientH, bankH, idsH, h: infoH },
columns,
headerCells,
rows,
align,
padLeft,
padRight,
padY,
cw,
table,
guardH,
tail: { boxW, labelW: boxW - valueW, valueW, leftW: wordsW, stack, slotRows, stackH: stackH2, dueText, dueSize: dueFit.size, wordsH, reverseH, leftH: wordsBlockH, body },
closing: { notes, notesBeside, notesH, flowH, signH, notesW, bandH, h: closingH },
gaps: { headRule: g.headRule, infoTop: g.infoTop, infoBottom: g.infoBottom, band: g.band, tail: g.tail, close: g.close },
tailH,
closingH,
};
}