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.
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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 MonolithVariant } 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 = 4;
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
|
||||
/** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
|
||||
export const atRate = (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)));
|
||||
}
|
||||
|
||||
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1;
|
||||
|
||||
export interface StackLine {
|
||||
kind: "cancelled" | "date" | "due" | "number";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The right-hand stack under the brand: the design's date and invoice number, plus a due date and the cancelled tag. */
|
||||
export function stackLines(model: RenderModel): StackLine[] {
|
||||
const out: StackLine[] = [];
|
||||
if (model.cancelled) out.push({ kind: "cancelled", text: "CANCELLED" });
|
||||
out.push({ kind: "date", text: model.meta.date });
|
||||
if (model.meta.dueDate) out.push({ kind: "due", text: `Due: ${model.meta.dueDate}` });
|
||||
out.push({ kind: "number", text: `${model.meta.numberLabel.toUpperCase()} ${model.meta.number}` });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function signatureOptions(v: MonolithVariant): 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 };
|
||||
}
|
||||
|
||||
export const roleOf = (v: MonolithVariant, kind: StackLine["kind"]): TypeToken => (kind === "date" || kind === "due" ? v.roles.metaDate : v.roles.metaNo);
|
||||
|
||||
/** The table's description (+ HSN/SAC sub-line), price, qty and amount cells. */
|
||||
export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number): TableCellValue[][] {
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, priceW, 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, rateParts, item.qtyText, item.amountText];
|
||||
});
|
||||
}
|
||||
|
||||
/** The footer's payment grid: the account holder as a plain line, then label : value rows. */
|
||||
export function paymentRows(model: RenderModel): Array<{ label: string; value: string }> {
|
||||
return model.bank ? model.bank.lines : [];
|
||||
}
|
||||
|
||||
export interface MonolithPlan {
|
||||
v: MonolithVariant;
|
||||
W: number;
|
||||
head: {
|
||||
titleSize: number;
|
||||
titleW: number;
|
||||
stackW: number;
|
||||
lines: StackLine[];
|
||||
showName: boolean;
|
||||
nameW: number;
|
||||
nameH: number;
|
||||
/** Height of the brand zone (name or logo box) including its top offset and the gap below. */
|
||||
zoneH: number;
|
||||
zoneTop: number;
|
||||
nameOffset: number;
|
||||
stackH: number;
|
||||
h: number;
|
||||
};
|
||||
info: { clientW: number; hasIds: boolean; idsH: number; clientH: number; h: number };
|
||||
columns: ColSpec[];
|
||||
headerCells: string[];
|
||||
rows: TableCellValue[][];
|
||||
align: Array<"left" | "center" | "right">;
|
||||
padLeft: number[];
|
||||
padRight: number[];
|
||||
padY: number;
|
||||
headPadY: number;
|
||||
cw: { price: number; qty: number; amount: number };
|
||||
table: ItemsTablePlan;
|
||||
guardH: number;
|
||||
tail: { boxW: number; labelW: number; valueW: number; leftW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rowH: number; boxH: number; wordsH: number; reverseH: number; leftH: number; body: number };
|
||||
closing: {
|
||||
leftW: number;
|
||||
notes: string[];
|
||||
notesBeside: boolean;
|
||||
notesH: number;
|
||||
flowH: number;
|
||||
signH: number;
|
||||
bandH: number;
|
||||
valueW: number;
|
||||
payH: number;
|
||||
vendorH: number;
|
||||
footerH: number;
|
||||
h: number;
|
||||
};
|
||||
gaps: { band: number; infoTop: number; infoBottom: number; closeBand: number; closeBottom: number };
|
||||
tailH: number;
|
||||
closingH: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the layout must know before rendering: the right-hand stack (it reserves the logo's whole box, so the
|
||||
* date and number lines flow under the logo), the title size, the columns, the totals box and the heights of the tail
|
||||
* and the closing block (their minPresenceAhead values), each a hair high rather than low.
|
||||
*/
|
||||
export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVariant, logo: PlacedLogo | null, includesName: boolean): MonolithPlan {
|
||||
const m = getMeasurer();
|
||||
const r = v.roles;
|
||||
const W = frame.contentWidth;
|
||||
const s = model.supplier;
|
||||
const c = model.client;
|
||||
|
||||
// --- header: the title at the left, the brand zone and the date / number stack at the right -----------------
|
||||
const lines = stackLines(model);
|
||||
const showName = !(logo && includesName) && s.name.trim() !== "";
|
||||
const markBlock = logo ? logo.w + (showName ? v.stack.markGap : 0) : 0;
|
||||
const natural = Math.max(...lines.map((l) => widthOf(roleOf(v, l.kind), l.text)), showName ? widthOf(r.brand, s.name) + markBlock : markBlock);
|
||||
let stackW = Math.min(Math.max(v.stack.minW, natural), Math.floor(W * v.stack.maxShare));
|
||||
stackW = Math.max(stackW, markBlock + (showName ? v.stack.minNameW : 0), logo?.w ?? 0);
|
||||
const nameW = showName ? stackW - markBlock : 0;
|
||||
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
|
||||
const zoneTop = logo ? v.stack.logoTop : v.stack.nameTop;
|
||||
const zoneBody = logo ? Math.max(logo.h, nameH) : nameH;
|
||||
const zoneH = zoneTop + zoneBody + (logo ? v.stack.logoGap : 0);
|
||||
const nameOffset = logo ? Math.max(0, (logo.h - nameH) / 2) : 0;
|
||||
let linesH = 0;
|
||||
for (const l of lines) linesH += idLines(roleOf(v, l.kind), l.text, stackW) * leading(roleOf(v, l.kind));
|
||||
const stackH = zoneH + linesH;
|
||||
const titleW = Math.max(40, W - stackW - v.titleGap - v.titleIndent);
|
||||
const tFit = fitSize(r.title, model.docTitle, titleW);
|
||||
const headH = Math.max(stackH, leading(r.title));
|
||||
|
||||
// --- info row: BILLED TO at the left, the supplier's identifiers at the right ---------------------------------
|
||||
const gstinLines = slotHeights.gstinLines(model, "supplier");
|
||||
const hasIds = gstinLines + (s.pan ? 1 : 0) > 0;
|
||||
const clientW = hasIds ? W - v.sideW - v.sideGap : W;
|
||||
let clientH = leading(r.heading) + v.headingGap + 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 idsH = hasIds ? leading(r.heading) + v.headingGap + (gstinLines ? idLines(r.body, GSTIN_SAMPLE, v.sideW) * leading(r.body) : 0) + (s.pan ? idLines(r.body, `PAN: ${s.pan}`, v.sideW) * leading(r.body) : 0) : 0;
|
||||
const infoH = Math.max(clientH, idsH);
|
||||
|
||||
// --- table ---------------------------------------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6;
|
||||
const cw = {
|
||||
price: Math.max(t.cols.price, need(priced, "Price", t.numPad)),
|
||||
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
|
||||
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }];
|
||||
const headerCells = ["Description", "Price", "Qty", "Total"];
|
||||
const align: MonolithPlan["align"] = ["left", "right", "center", "right"];
|
||||
const padLeft = [t.descInset, 0, 0, 0];
|
||||
const padRight = [8, t.numPad, 0, t.numPad];
|
||||
const headPadY = (t.headH - 2 * v.heavyRule - leading(r.tableHead)) / 2;
|
||||
const rows = tableRows(model, v, cw.price - t.numPad - 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, border: 0, headBorder: v.heavyRule, headerTailH: v.heavyRule, tailH, contentH: frame.contentHeight });
|
||||
|
||||
// --- totals: the stack, a rule, the Total row; amount in 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, atRate);
|
||||
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.max(o.labelW, Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)), measureToken(m, r.total, "Total"))) + 12);
|
||||
const valueW = Math.max(o.valueW, Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)), measureToken(m, r.total, tt.totalText))) + o.valuePad + 6);
|
||||
const boxW = Math.max(o.boxW, labelW + valueW);
|
||||
const rowH = leading(r.totalLabel) + o.rowGap;
|
||||
const boxH = o.top + (stack.length + slotRows) * rowH + o.ruleGap + v.rule + o.totalGap + leading(r.total);
|
||||
const leftW = Math.max(0, W - boxW - o.leftGap);
|
||||
const wordsH = slotHeights.amountInWords(model, leftW, { role: r.small, labelRole: r.smallStrong });
|
||||
const reverseH = slotHeights.reverseCharge(model, leftW, r.small);
|
||||
const leftH = wordsH || reverseH ? o.top + wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0;
|
||||
const body = Math.max(boxH, leftH);
|
||||
const tailH = v.rule + body;
|
||||
|
||||
// --- closing: notes and signature, then the footer between two rules ------------------------------------------------
|
||||
const cl = v.closing;
|
||||
const cLeftW = W - v.sideW - v.sideGap;
|
||||
const notes = model.notesLines;
|
||||
const notesH = notes.length ? leading(r.smallStrong) + LABEL_GAP + notes.reduce((h, l) => h + textHeight(r.small, l, cLeftW), 0) : 0;
|
||||
const notesBeside = notes.length === 0 || notesH <= cl.notesBesideMax;
|
||||
const flowH = notesBeside ? 0 : notesH;
|
||||
const signH = signatoryHeight(model, v.sideW, signatureOptions(v));
|
||||
const bandH = Math.max(notesBeside ? notesH : 0, signH);
|
||||
const valueColW = cLeftW - cl.labelW - cl.colonW;
|
||||
const payH = model.bank
|
||||
? leading(r.heading) + cl.headingGap + textHeight(r.body, model.bank.accountName, cLeftW) + paymentRows(model).reduce((h, x) => h + textHeight(r.body, x.value, valueColW), 0)
|
||||
: 0;
|
||||
let vendorH = textHeight(r.vendor, s.name, v.sideW) + cl.headingGap;
|
||||
for (const l of s.addressLines) vendorH += textHeight(r.body, l, v.sideW);
|
||||
if (s.phone) vendorH += idLines(r.body, s.phone, v.sideW) * leading(r.body);
|
||||
if (s.email) vendorH += idLines(r.body, s.email, v.sideW) * leading(r.body);
|
||||
const footerH = Math.max(payH, vendorH);
|
||||
const cancelledH = model.cancelled ? 10 + textHeight(r.small, CANCELLED_SENTENCE, W) : 0;
|
||||
const closingFor = (g: { closeBand: number; closeBottom: number }) => bandH + g.closeBand + v.rule + cl.top + footerH + g.closeBottom + v.rule + cancelledH;
|
||||
|
||||
// --- gaps: the empty band under the info row 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 closingNominal = closingFor({ closeBand: ranges.closeBand.nominal, closeBottom: ranges.closeBottom.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 + v.headRuleGap + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailH + notesBlock + closingNominal;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
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.
|
||||
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) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const closingH = closingFor(g);
|
||||
const padY = n ? g.rowPad / (2 * n) : padYn;
|
||||
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: tFit.size, titleW, stackW, lines, showName, nameW, nameH, zoneH, zoneTop, nameOffset, stackH, h: headH },
|
||||
info: { clientW, hasIds, idsH, clientH, h: infoH },
|
||||
columns,
|
||||
headerCells,
|
||||
rows,
|
||||
align,
|
||||
padLeft,
|
||||
padRight,
|
||||
padY,
|
||||
headPadY,
|
||||
cw,
|
||||
table,
|
||||
guardH,
|
||||
tail: { boxW, labelW, valueW, leftW, stack, slotRows, rowH, boxH, wordsH, reverseH, leftH, body },
|
||||
closing: { leftW: cLeftW, notes, notesBeside, notesH, flowH, signH, bandH, valueW: valueColW, payH, vendorH, footerH, h: closingH },
|
||||
gaps: { band: g.band, infoTop: g.infoTop, infoBottom: g.infoBottom, closeBand: g.closeBand, closeBottom: g.closeBottom },
|
||||
tailH,
|
||||
closingH,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user