import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight } from "../../engine/textplan"; import { fitSize } from "../../blocks/FitText"; import { innerRulePadRight, 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 { INK, leading, type CobaltVariant } 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"; /** Narrowest the footer address column may get before the phone and e-mail shrink to make room. */ const MIN_ADDRESS_W = 120; /** Space between a mark and the tagline under a wordmark logo. */ const WORDMARK_TAG_GAP = 3; const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; /** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */ export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`); 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)) + 1); } const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1; export interface MetaLine { strong: boolean; text: string; cancelled?: boolean; } export function metaLines(model: RenderModel): MetaLine[] { const out: MetaLine[] = [...(model.cancelled ? [{ strong: true, text: "CANCELLED", cancelled: true }] : []), { strong: true, text: `${model.meta.numberLabel.replace(/\.$/, "")} : ${model.meta.number}` }, { strong: false, text: model.meta.date }]; if (model.meta.dueDate) out.push({ strong: false, text: `Due date : ${model.meta.dueDate}` }); if (model.client.poNumber) out.push({ strong: false, text: `PO : ${model.client.poNumber}` }); return out; } /** The bank block's lines: "Label : value", the design's "Bank Name : ..." and "Account Number : ...". */ export function bankLines(model: RenderModel): string[] { const bank = model.bank; if (!bank) return []; const rename: Record = { Bank: "Bank Name", "Account No.": "Account Number" }; const out = bank.lines.map((l) => `${rename[l.label] ?? l.label} : ${l.value}`); const at = bank.lines.findIndex((l) => l.label === "Bank") + 1; out.splice(at, 0, `Account Name : ${bank.accountName}`); return out; } export function signatureOptions(v: CobaltVariant): SignatoryOptions { const r = v.roles; return { roles: { name: r.signName, designation: r.signTitle, fine: r.fine }, align: "right", rule: null, color: INK, imageHeight: v.closing.signatureImageH }; } /** The table's no., description (+ HSN/SAC sub-line), qty, price and amount cells. */ export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number): TableCellValue[][] { const r = v.roles; const hsn = Slot.HsnSac(model); return model.items.map((item, i) => { const rate = rateCell(item, 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 [String(i + 1), desc, item.qtyText, rateParts, item.amountText]; }); } export interface ContactItem { icon: "phone" | "email" | "location"; lines: string[]; /** Identifiers (phone, e-mail) shrink instead of wrapping; the address wraps. */ id: boolean; /** Width of the text box beside the icon. */ textW: number; h: number; } export interface CobaltPlan { v: CobaltVariant; W: number; head: { titleSize: number; titleW: number; leftW: number; mode: "name" | "wordmark" | "none"; showName: boolean; markW: number; nameW: number; nameH: number; tagline: boolean; taglineH: number; leftH: number; h: number; ruleGap: number; ruleText: string; ruleTextW: number; ruleW: number; ruleH: number; }; info: { metaW: number; clientW: number; lines: MetaLine[]; metaH: number; clientH: number; h: number }; columns: ColSpec[]; headerCells: string[]; rows: TableCellValue[][]; align: Array<"left" | "center" | "right">; headAlign: Array<"left" | "center" | "right">; padLeft: number[]; padRight: number[]; padY: number; headPadY: number; cw: { no: number; qty: number; price: number; amount: number; priceInset: number }; table: ItemsTablePlan; guardH: number; tail: { stack: Array<{ label: string; amount: string }>; slotRows: number; stackH: number; rightW: number; valueW: number; leftW: number; leftTop: number; /** The PAYMENT METHOD bar is level with the GRAND TOTAL bar (the design) rather than beside the first totals row. */ aligned: boolean; bank: string[]; bankH: number; wordsH: number; reverseH: number; rightH: number; leftH: number; body: number; }; closing: { leftW: number; signCol: number; terms: string[]; termsBeside: boolean; termsH: number; flowW: number; flowH: number; leftH: number; signH: number; rowH: number; contacts: ContactItem[]; contactGap: number; contactsW: number; contactsH: number; footerH: number; h: number; }; gaps: { client: number; table: number; tail: number; terms: number; footer: number }; tailH: number; closingH: number; } function planContacts(model: RenderModel, v: CobaltVariant, W: number): { items: ContactItem[]; gap: number; width: number; h: number } { const s = model.supplier; const r = v.roles; const c = v.closing; const raw: Array> = []; if (s.phone) raw.push({ icon: "phone", lines: [s.phone], id: true }); if (s.email) raw.push({ icon: "email", lines: [s.email], id: true }); if (s.addressLines.length) raw.push({ icon: "location", lines: s.addressLines, id: false }); const n = raw.length; if (n === 0) return { items: [], gap: 0, width: 0, h: 0 }; const natural = raw.map((x) => Math.max(...x.lines.map((l) => widthOf(r.contact, l)))); const iconBlock = c.icon.size + c.icon.gap; const budget = W - n * iconBlock - (n - 1) * c.minContactGap; let texts = natural; if (natural.reduce((a, b) => a + b, 0) > budget) { // Identifiers keep their natural width while the address (which wraps) keeps a usable column; otherwise they shrink // (to their minimum size, then chunk) to a share of the row and the address wraps in what is left. const ids = raw.reduce((a, x, i) => a + (x.id ? natural[i] : 0), 0); const free = raw.filter((x) => !x.id).length; const room = free ? (budget - ids) / free : 0; const cap = free === 0 ? Math.floor(budget / n) : room >= MIN_ADDRESS_W ? Infinity : Math.floor(budget * 0.3); const idW = raw.map((x, i) => (x.id ? Math.min(natural[i], cap) : 0)); const rest = budget - idW.reduce((a, b) => a + b, 0); texts = raw.map((x, i) => (x.id ? idW[i] : Math.max(60, Math.floor(rest / Math.max(1, free))))); } const used = texts.reduce((a, b) => a + b, 0) + n * iconBlock; const gap = n > 1 ? Math.max(c.minContactGap, Math.min(c.contactGap, (W - used) / (n - 1))) : 0; const items = raw.map((x, i): ContactItem => { const textW = texts[i]; const h = x.id ? idLines(r.contact, x.lines[0], textW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, textW), 0); return { ...x, textW, h }; }); const width = used + (n - 1) * gap; return { items, gap, width, h: Math.max(c.icon.size, ...items.map((x) => x.h + Math.max(0, (c.icon.size - leading(r.contact)) / 2))) }; } /** * Everything the layout must know before rendering: the header (the INVOICE title's size and the left block's mode), * the info row, the five table columns, the totals' two columns, the closing block and the footer's contact row, and * the heights of the tail and the closing block (their minPresenceAhead values), each a hair high rather than low. */ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVariant, logo: PlacedLogo | null, includesName: boolean): CobaltPlan { const m = getMeasurer(); const r = v.roles; const W = frame.contentWidth; const s = model.supplier; const c = model.client; const h = v.head; // --- header ------------------------------------------------------------------------------------------------------------ const tFit = fitSize(r.title, model.docTitle, Math.floor(W * 0.62)); const titleW = Math.ceil(tFit.width) + 2; const leftW = W - titleW - h.titleGap; const showName = !(logo && includesName) && s.name.trim() !== ""; const mode: "name" | "wordmark" | "none" = showName ? "name" : logo ? "wordmark" : "none"; const markW = logo && showName ? logo.w : 0; const nameW = showName ? Math.max(h.minNameW, leftW - (markW ? markW + h.markGap : 0)) : 0; const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0; const tagline = slotHeights.gstinLines(model, "supplier") > 0; const tagW = mode === "name" ? nameW : leftW; const taglineH = tagline ? idLines(r.tagline, GSTIN_SAMPLE, tagW) * leading(r.tagline) : 0; let headLeftH = 0; if (mode === "name") headLeftH = Math.max(h.nameTop + nameH + (tagline ? h.taglineGap + taglineH : 0), logo ? h.logoTop + logo.h : 0); else if (mode === "wordmark") headLeftH = h.logoTop + (logo?.h ?? 0) + (tagline ? WORDMARK_TAG_GAP + taglineH : 0); const headH = Math.max(headLeftH, leading(r.title)); const ruleText = s.pan ? `PAN: ${s.pan}` : ""; const ruleH = leading(r.ruleText); const ruleTextW = ruleText ? Math.min(widthOf(r.ruleText, ruleText), Math.floor(W * 0.38)) : 0; const ruleW = W - (ruleTextW ? ruleTextW + h.ruleTextGap : 0); const ruleGap = Math.max(h.ruleMinGap, h.ruleTop - headH); // --- info row: Billed to at the left, the invoice numbers at the right -------------------------------------------------- const lines = metaLines(model); const metaW = Math.min(Math.floor(W * v.info.metaMaxShare), Math.max(v.info.metaMinW, ...lines.map((l) => widthOf(l.strong ? r.metaStrong : r.metaLine, l.text)))); const clientW = W - metaW - v.info.clientGap; let clientH = leading(r.label) + textHeight(r.clientName, c.name, clientW); const clientLines = c.addressLines.length + slotHeights.gstinLines(model, "client") + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW); if (clientLines > 0) clientH += v.info.nameGap; for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, clientW); clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW) * leading(r.clientLine); const metaH = lines.reduce((a, l) => a + idLines(l.strong ? r.metaStrong : r.metaLine, l.text, metaW) * leading(r.metaLine), 0); const infoH = Math.max(clientH, metaH); // --- table: NO | DESCRIPTION | QTY | PRICE | TOTAL --------------------------------------------------------------------------- const t = v.table; const rule = t.separator; const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x)))); const priced = model.items.map((i) => priceOf(i)); const amountTexts = model.items.map((i) => i.amountText); const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12); const qty = Math.max(t.cols.qty, widest(r.tableBody, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 12 + rule); const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6; const price = Math.max(t.cols.price, priceNeed); const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4)); const amount = Math.max(t.cols.amount, Math.max(widest(r.tableBody, amountTexts), widest(r.tableHead, ["Total"])) + t.amountInset + 8); const columns: ColSpec[] = [{ fixed: no }, { fill: true }, { fixed: qty }, { fixed: price }, { fixed: amount }]; const headerCells = ["No", "Description", "Qty", "Price", "Total"]; const align: CobaltPlan["align"] = ["center", "left", "center", "right", "right"]; const headAlign: CobaltPlan["headAlign"] = ["center", "center", "center", "right", "center"]; const padLeft = [0, t.descInset, 0, 0, 0]; // The inner rule counts toward the inset, so the price ends `priceInset` short of its column edge as in the design. const padRight = [0, 6, 0, priceInset - rule, t.amountInset]; const planPadRight = padRight.map((p, i) => p + innerRulePadRight(5, rule)[i]); const headPadY = (t.headH - leading(r.tableHead)) / 2; const rows = tableRows(model, v, price - rule - padRight[3] - 2); const n = rows.length; const padY = t.padY; const planTable = (tailH: number) => planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight: planPadRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight }); // --- totals: Sub Total rows and the GRAND TOTAL bar at the right, the PAYMENT METHOD bar and the bank lines at the left ------ const tt = model.totals; const o = v.totals; const stack: Array<{ label: string; amount: string }> = [{ label: labelWithColon("Sub Total"), amount: tt.subtotalText }]; if (tt.discountText) stack.push({ label: labelWithColon("Discount"), amount: `- ${tt.discountText}` }); if (tt.taxableText) stack.push({ label: labelWithColon("Taxable Value"), amount: tt.taxableText }); const slotRows = slotHeights.taxRowCount(model); const stackH = (stack.length + slotRows) * leading(r.totalLabel); const valueW = o.rightW - o.labelW; const wordsH = slotHeights.amountInWords(model, o.rightW, { role: r.words, labelRole: r.wordsLabel }); const reverseH = slotHeights.reverseCharge(model, o.rightW, r.words); const rightH = stackH + o.barGap + o.barH + (wordsH ? o.wordsGap + wordsH : 0) + (reverseH ? o.reverseGap + reverseH : 0); const tailLeftW = W - o.rightW - o.leftGap; const bank = bankLines(model); const bankH = bank.reduce((a, l) => a + textHeight(r.bank, l, tailLeftW - o.barInset), 0); const leftBlockH = o.barH + o.bankGap + bankH; const leftHFor = (top: number) => (model.bank ? top + leftBlockH : 0); // --- closing ------------------------------------------------------------------------------------------------------------------ const cl = v.closing; const signCol = cl.signW + cl.signInset; const cLeftW = W - signCol - 8; const terms = model.notesLines; const termsH = terms.length ? leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, cLeftW), 0) : 0; const termsBeside = terms.length === 0 || termsH <= cl.notesBesideMax; const flowW = W - 2; const flowH = termsBeside ? 0 : leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, flowW), 0); const signH = signatoryHeight(model, cl.signW, signatureOptions(v)); const contacts = planContacts(model, v, W); const footerRuleH = h.rule.accent; const footerH = footerRuleH + (contacts.items.length ? cl.ruleToContacts + contacts.h : 0); const leftBase = cl.divider.thickness + cl.dividerGap + cl.thanksH; const cancelledH = model.cancelled ? 10 + textHeight(r.fine, CANCELLED_SENTENCE, W) : 0; const closingFor = (g: { terms: number; footer: number }) => { const leftHh = leftBase + (termsBeside && terms.length ? g.terms + termsH : 0); const rowH = Math.max(leftHh, signH); return { leftH: leftHh, rowH, h: rowH + g.footer + footerH + cancelledH }; }; // --- gaps ----------------------------------------------------------------------------------------------------------------------- const ranges = { client: v.gaps.client, table: v.gaps.table, tail: v.gaps.tail, terms: termsBeside && terms.length ? v.gaps.terms : { nominal: 0, min: 0 }, footer: v.gaps.footer }; const nominalClosing = closingFor({ terms: ranges.terms.nominal, footer: ranges.footer.nominal }); const table0 = planTable(0); const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0); const notesBlock = termsBeside ? 0 : NOTES_FLOW_GAP + flowH; const nominalFor = (tailBody: number) => headH + ruleGap + ruleH + ranges.client.nominal + infoH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + notesBlock + nominalClosing.h; const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0); // The PAYMENT METHOD bar sits level with the GRAND TOTAL bar, as in the design, whenever the invoice still fits its page // that way; with more totals rows than the design's two it moves up beside the first row, which saves up to the stack's height. const alignedTop = stackH + o.barGap; const aligned = nominalFor(Math.max(rightH, leftHFor(alignedTop))) - slack <= frame.contentHeight || !model.bank; const leftTop = aligned ? alignedTop : 0; const leftH = leftHFor(leftTop); const tailBody = Math.max(rightH, leftH); const nominalTotal = nominalFor(tailBody); 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 - nominalClosing.h - frame.contentHeight; const { terms: termsRange, footer: footerRange, ...beforeRanges } = ranges; const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0); // 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<"client" | "table" | "tail" | "terms" | "footer", 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))), terms: termsRange.nominal, footer: footerRange.nominal }; else g = elastic(ranges, frame.heightDeficit); const closing = closingFor(g); const tailH = g.tail + tailBody; const table = planTable(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, leftW, mode, showName, markW, nameW, nameH, tagline, taglineH, leftH: headLeftH, h: headH, ruleGap, ruleText, ruleTextW, ruleW, ruleH }, info: { metaW, clientW, lines, metaH, clientH, h: infoH }, columns, headerCells, rows, align, headAlign, padLeft, padRight, padY, headPadY, cw: { no, qty, price, amount, priceInset }, table, guardH, tail: { stack, slotRows, stackH, rightW: o.rightW, valueW, leftW: tailLeftW, leftTop, aligned, bank, bankH, wordsH, reverseH, rightH, leftH, body: tailBody }, closing: { leftW: cLeftW, signCol, terms, termsBeside, termsH, flowW, flowH, leftH: closing.leftH, signH, rowH: closing.rowH, contacts: contacts.items, contactGap: contacts.gap, contactsW: contacts.width, contactsH: contacts.h, footerH, h: closing.h }, gaps: { client: g.client, table: g.table, tail: g.tail, terms: g.terms, footer: g.footer }, tailH, closingH: closing.h, }; }