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); // The closing block's own gaps cannot help page 1 once it has moved to page 2. const { closeBand: closeBandRange, closeBottom: closeBottomRange, ...beforeRanges } = ranges; const slackBefore = Object.values(beforeRanges).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 (its own gaps stay nominal: squeezing them would take // a share of the deficit away from the gaps that do help page 1). 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); let g: Record<"band" | "infoTop" | "infoBottom" | "closeBand" | "closeBottom" | "rowPad", 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))), closeBand: closeBandRange.nominal, closeBottom: closeBottomRange.nominal }; else g = elastic(ranges, frame.heightDeficit); 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, }; }