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, }; }