import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight, textLines } from "../../engine/textplan"; import { DOME_BOX } from "../../decor/Asterisk"; 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 { TypeToken } from "../../fonts/roles"; import type { RenderModel } from "../../model/build"; import type { PlacedLogo } from "../contract"; import { bankLines, CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan"; import type { CitrusVariant, PurpleVariant } from "./variant"; /** How both templates print a tax row's label: "CGST @ 9%". */ export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label); export const leading = (t: TypeToken) => t.leading as number; const round2 = (n: number) => Math.round(n * 100) / 100; /** Space between the client label and the name pill, and between the pill and the address lines. */ export const CLIENT_LABEL_GAP = 9; export const CLIENT_PILL_GAP = 6; /** Gap between a mark and the vendor name beside it. */ export const MARK_GAP = 8; /** Gap between the stack's last row and the TOTAL pill. */ export const PILL_GAP = 8; export const REVERSE_GAP = 4; /** Width of a bullet dot and the space after it. */ export const BULLET = { dot: 3.8, textAt: 12.7 }; /** Rows of the bullet column are this far apart on top of their line height. */ export const BULLET_GAP = 3; export const CLOSING_COLUMN_GAP = 12; /** Space above terms that flow as their own block (more bullets than fit beside the signature). */ export const NOTES_FLOW_GAP = 14; /** Top of the dome below the totals box top (reference: 565.3 against the subtotal row at 541.5). */ export const DOME_TOP = 22; /** The notes as bullet lines: one bullet per note line. */ export const termsLines = (model: RenderModel): string[] => model.notesLines; export interface ContactEntry { icon: "phone" | "email" | "location"; lines: string[]; /** Identifiers (phone, e-mail) shrink instead of wrapping; free text wraps. */ id: boolean; } /** The supplier's phone, e-mail and address as an icon list. */ export function contactEntries(model: RenderModel): ContactEntry[] { const s = model.supplier; const out: ContactEntry[] = []; if (s.phone) out.push({ icon: "phone", lines: [s.phone], id: true }); if (s.email) out.push({ icon: "email", lines: [s.email], id: true }); if (s.addressLines.length) out.push({ icon: "location", lines: s.addressLines, id: false }); return out; } export function signatureOptions(roles: { name: TypeToken; designation: TypeToken; fine: TypeToken }, imageHeight: number, color: string, align: "left" | "right"): SignatoryOptions { return { roles, align, rule: { width: 0.75, color }, color, imageHeight }; } /** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */ export 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); } /** Plain shrink-wrapped width of a one-line text. */ export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1; export const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; /** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */ export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] { const hsn = Slot.HsnSac(model); return model.items.map((item, i) => { const rate = rateCell(item, rateW, body); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]); const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines; return [desc, item.qtyText, rateParts, item.amountText]; }); } /** Width of a numeric column: the design's, or wider when the widest value or the header needs it. */ export function numericColW(base: number, body: TypeToken, head: TypeToken, texts: string[], headText: string, pad = 6): number { const m = getMeasurer(); const widest = Math.max(0, ...texts.map((t) => measureToken(m, body, t)), measureToken(m, head, headText)); return Math.max(base, Math.ceil(widest) + pad); } // --- Purple Pop --------------------------------------------------------------------------------------------- export interface PurplePlan { kind: "purple"; v: PurpleVariant; W: number; head: { innerW: number; pillText: string; pillSize: number; pillW: number; leftW: number; showName: boolean; nameW: number; nameLeft: number; boxH: number; linesH: number; h: number; }; client: { cellW: number; wordmarkW: number; pillW: number; pillBoxH: number; lineW: number; clientH: number }; title: { size: number; tax: boolean; h: number; w: number; pad: number }; rowH: number; band: { amountW: number; datesW: number; dateSize: number; lines: string[]; amountText: string; amountSize: number; h: number }; mode: "narrow" | "full"; tableLeft: number; tableW: number; columns: ColSpec[]; headerCells: string[]; rows: TableCellValue[][]; align: Array<"left" | "center" | "right">; padY: number; headPadY: number; table: ItemsTablePlan; guardH: number; tail: { gutter: number; boxW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; wordsW: number; wordsH: number; reverseH: number; body: number }; /** `beside`: the bullets sit next to the signature in the closing block; otherwise they flow as their own block before it. */ terms: { lines: string[]; beside: boolean; fillW: number; bulletW: number; bulletsH: number; signH: number; h: number; flowW: number; flowBulletW: number; flowH: number }; footer: { bank: string[]; contacts: ContactEntry[]; bankH: number; contactH: number; addressH: number; textW: { contact: number; address: number }; h: number }; gaps: { client: number; rule: number; band: number; table: number; tail: number }; tailH: number; closingH: number; /** Page-space y of the first-page asterisk's top, or null when the table is full width. */ asteriskY: number | null; } export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVariant, logo: PlacedLogo | null, includesName: boolean): PurplePlan { const m = getMeasurer(); const r = v.roles; const W = frame.contentWidth; // --- header: name (and the opt-in logo) at the left, the INVOICE NO pill at the right ------------------- const innerW = W - v.head.nameInset - v.head.pillInset; const showName = !(logo && includesName) && model.supplier.name.trim() !== ""; const markW = logo?.w ?? 0; const nameLeft = markW > 0 ? markW + (showName ? MARK_GAP : 0) : 0; const label = model.meta.numberLabel.replace(/\.$/, ""); const pillText = `${label} : ${model.meta.number}`; const pillTextMax = Math.max(40, innerW - 12 - nameLeft - v.head.minNameW - 2 * v.head.pillPadX); const fit = fitSize(r.pill, pillText, pillTextMax); const pillW = Math.ceil(fit.width) + 2 * v.head.pillPadX + 2; const leftW = innerW - pillW - 12; const nameW = showName ? Math.min(v.head.nameMaxW, leftW - nameLeft) : 0; const nameH = showName ? textHeight(r.brand, model.supplier.name, nameW) : 0; const boxH = Math.max(v.head.pillH, nameH, logo?.h ?? 0); const s = model.supplier; const linesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small); const headH = Math.max(boxH + linesH, v.head.pillH); // --- client column and the wordmark ------------------------------------------------------------------------- const cellW = v.client.colW - v.client.colGap; const c = model.client; const pillNatural = widthOf(r.clientName, c.name); const pillW2 = Math.min(cellW, pillNatural + 2 * v.client.pillPadX); const pillLines = textLines(r.clientName, c.name, pillW2 - 2 * v.client.pillPadX); const pillBoxH = Math.max(v.client.pillH, pillLines * leading(r.clientName) + 2 * ((v.client.pillH - leading(r.clientName)) / 2)); const lineW = cellW - v.client.inset; let clientH = leading(r.label) + CLIENT_LABEL_GAP + pillBoxH + CLIENT_PILL_GAP; for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, lineW); clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, lineW) * leading(r.clientLine); const wordmarkW = W - v.client.colW - v.client.wordmarkInset; const tax = model.docType === "tax_invoice"; const wmPad = Math.abs((r.wordmark.tracking ?? 0) * r.wordmark.size); const wm = fitSize(r.wordmark, "Invoice", wordmarkW - wmPad); const titleH = leading(r.wordmark) + (tax ? leading(r.taxCaption) + 2 : 0); const rowH = Math.max(clientH, titleH); // --- amount due and dates --------------------------------------------------------------------------------- const bandInner = W - v.band.amountInset - v.band.datesInset; const dateLines = [`Invoice Date : ${model.meta.date}`, ...(model.meta.dueDate ? [`Due Date : ${model.meta.dueDate}`] : []), ...(c.poNumber ? [`PO : ${c.poNumber}`] : [])]; const widest = dateLines.reduce((a, b) => (widthOf(r.date, b) > widthOf(r.date, a) ? b : a)); const amountMin = 150; const dFit = fitSize(r.date, widest, bandInner - v.band.gap - amountMin); const datesW = Math.ceil(dFit.width) + 2; const amountW = bandInner - v.band.gap - datesW; const aFit = fitSize(r.amount, model.totals.totalText, amountW); const bandH = Math.max(leading(r.label) + leading(r.amount) + 2, dateLines.length * leading(r.date)); // --- table: the narrow variant (beside the asterisk) or the full width --------------------------------------- const t = v.table; const rowsFor = (rateW: number) => tableRows(model, rateW, r.tableBody, r.tableSub); const headerCells = ["Service", "Qty", "Rate", "Amount"]; const align: PurplePlan["align"] = ["left", "center", "right", "right"]; const padY = (t.rowH - leading(r.tableBody)) / 2; const headPadY = (t.headH - leading(r.tableHead)) / 2; const priced = model.items.map((i) => priceOf(i)); const build = (left: number, base: { qty: number; rate: number; amount: number }) => { const tableW = round2(W - left - t.right); const inner = tableW - 2 * t.inset; const cw = { qty: numericColW(base.qty, r.tableBody, r.tableHead, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"), rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"), amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"), }; const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }]; const rows = rowsFor(cw.rate - 2); return { tableW, inner, cw, columns, rows, descW: inner - cw.qty - cw.rate - cw.amount }; }; const plan = (b: ReturnType, tailH: number) => planItemsTable({ columns: b.columns, gap: 0, width: b.inner, header: headerCells, rows: b.rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight, }); const narrowB = build(t.narrowLeft, t.cols); let mode: "narrow" | "full" = "full"; if (model.items.length <= 3 && narrowB.descW >= t.narrowDescMin) { const probe = plan(narrowB, 0); if (probe.rowLines.every((n) => n <= 2)) mode = "narrow"; } const fullB = build(t.fullLeft, { qty: 46, rate: 100, amount: 108 }); const b = mode === "narrow" ? narrowB : fullB; const tableLeft = mode === "narrow" ? t.narrowLeft : t.fullLeft; // --- totals: the stack, the TOTAL pill, amount in words --------------------------------------------------------- const tt = model.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 gutter = round2(W - v.totals.boxW); const boxW = v.totals.boxW; const taxTexts = slotHeights.taxRowTexts(model, atRate); const valueTexts = [tt.subtotalText, tt.totalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)]; const valueW = Math.max(100, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2); const stackRowH = leading(r.totalLabel) + v.totals.rowGap; const stackH = (stack.length + slotRows) * stackRowH; const wordsW = boxW - v.totals.labelInset - v.totals.pillValueInset; const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel }); const reverseH = slotHeights.reverseCharge(model, wordsW, r.small); const boxBody = stackH + PILL_GAP + v.totals.pillH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0); // The dome in the left gutter must not make the row taller than its totals box. const tailBody = Math.max(boxBody, DOME_TOP + DOME_BOX.h * (v.dome.w / DOME_BOX.w)); // --- closing: terms and signature, the rule, the three footer columns ---------------------------------------------- const lines = termsLines(model); const signW = v.terms.signW; const fillW = W - (v.terms.labelInset + v.terms.labelW) - signW - 2 * CLOSING_COLUMN_GAP; const bulletW = fillW - BULLET.textAt; const bulletsH = lines.reduce((h, l) => h + textHeight(r.bullet, l, bulletW) + BULLET_GAP, 0) - (lines.length ? BULLET_GAP : 0); const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.ink, "right"); const signH = signatoryHeight(model, signW, sOpts); const beside = lines.length === 0 || bulletsH <= v.terms.besideMax; const flowW = W - v.terms.labelInset - 7; const flowBulletW = flowW - BULLET.textAt; const flowH = beside ? 0 : leading(r.termsLabel) + 4 + lines.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW) + BULLET_GAP, 0) - BULLET_GAP; const termsH = beside ? Math.max(lines.length ? leading(r.termsLabel) : 0, bulletsH, signH) : signH; const bank = bankLines(model); const bankH = model.bank ? leading(r.label) + 4 + bank.reduce((h, l) => h + textHeight(r.bank, l, v.footer.bankW), 0) : 0; const contacts = contactEntries(model); const contactText = v.footer.contactW - v.footer.iconW - v.footer.iconGap; const addressText = W - v.footer.addressLeft - v.footer.iconW - v.footer.iconGap; const phoneMail = contacts.filter((x) => x.icon !== "location"); const address = contacts.find((x) => x.icon === "location"); const contactH = phoneMail.reduce((h, x) => h + idLines(r.contact, x.lines[0], contactText) * leading(r.contact) + 1.5, 0); const addressH = address ? address.lines.reduce((h, l) => h + textHeight(r.contact, l, addressText), 0) : 0; const footerH = Math.max(bankH, contactH, addressH); let closingH = v.gaps.termsAbove + termsH + v.gaps.ruleGap + v.rule.w + v.gaps.ruleGap + footerH + 1.5; if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W); // --- gaps: the elastic ones give back Letter's missing height, or an overflow within their slack ------------------------ const ranges = { client: v.gaps.client, rule: v.gaps.rule, band: v.gaps.band, table: v.gaps.table, tail: v.gaps.tail }; const table0 = plan(b, 0); const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0); const tailNominal = ranges.tail.nominal + tailBody; const notesBlock = beside ? 0 : NOTES_FLOW_GAP + flowH; const nominalTotal = headH + ranges.client.nominal + rowH + ranges.rule.nominal + v.rule.w + ranges.band.nominal + bandH + ranges.table.nominal + tableTotal + tailNominal + notesBlock + closingH; 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 - closingH - frame.contentHeight; const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0; const deficit = Math.max(frame.heightDeficit, fits); const g = elastic(ranges, deficit); const tailH = g.tail + tailBody; const table = plan(b, tailH); const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (b.rows.length === 1 ? tailH : 0); const asteriskY = mode === "narrow" ? round2(frame.pad.top + headH + g.client + rowH + g.rule + v.rule.w + g.band + bandH + g.table + v.asterisk.offsetY) : null; return { kind: "purple", v, W, head: { innerW, pillText, pillSize: fit.size, pillW, leftW, showName, nameW, nameLeft, boxH, linesH, h: headH }, client: { cellW, wordmarkW, pillW: pillW2, pillBoxH, lineW, clientH }, title: { size: wm.size, tax, h: titleH, w: wordmarkW, pad: Math.abs((r.wordmark.tracking ?? 0) * wm.size) }, rowH, band: { amountW, datesW, dateSize: dFit.size, lines: dateLines, amountText: model.totals.totalText, amountSize: aFit.size, h: bandH }, mode, tableLeft, tableW: b.tableW, columns: b.columns, headerCells, rows: b.rows, align, padY, headPadY, table, guardH, tail: { gutter, boxW, valueW, stack, slotRows, stackRowH, stackH, wordsW, wordsH, reverseH, body: tailBody }, terms: { lines, beside, fillW, bulletW, bulletsH, signH, h: termsH, flowW, flowBulletW, flowH }, footer: { bank, contacts, bankH, contactH, addressH, textW: { contact: contactText, address: addressText }, h: footerH }, gaps: g, tailH, closingH, asteriskY, }; } // --- Citrus Split -------------------------------------------------------------------------------------------- export interface CitrusPlan { kind: "citrus"; v: CitrusVariant; W: number; /** The stripe's left edge from the content edge. */ Lw: number; panelW: number; /** The client block above the table (an invoice longer than a page), instead of beside the totals. */ clientAbove: boolean; head: { titleW: number; titleSize: number; tax: boolean; titleH: number; showName: boolean; nameW: number; nameLeft: number; nameH: number; boxH: number; h: number }; client: { w: number; h: number; nameW: number }; meta: { noW: number; dateW: number; dueW: number; amountW: number; spacer: number; hasDue: boolean; h: number }; tableLeft: number; tableW: number; columns: ColSpec[]; headerCells: TableCellValue[]; rows: TableCellValue[][]; align: Array<"left" | "center" | "right">; padLeft: number[]; padRight: number[]; padY: number; headPadY: number; cw: { qty: number; rate: number; amount: number }; table: ItemsTablePlan; guardH: number; tail: { leftW: number; valueW: number; barValueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; panelH: number; leftH: number; body: number; wordsW: number; wordsH: number; reverseH: number }; closing: { bank: string[]; terms: string[]; /** Terms in the closing's left column; otherwise a flowing block before the closing. */ termsBeside: boolean; flowW: number; flowBulletW: number; flowH: number; leftH: number; bankH: number; termsH: number; bulletW: number; contacts: ContactEntry[]; contactTextW: number; contactsH: number; idLinesH: number; signH: number; rightH: number; h: number; }; gaps: { client: number; meta: number; table: number; tail: number }; tailH: number; closingH: number; } export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVariant, logo: PlacedLogo | null, includesName: boolean): CitrusPlan { const m = getMeasurer(); const r = v.roles; const W = frame.contentWidth; const panelW = v.stripeW; const Lw = round2(W - panelW); const c = model.client; const s = model.supplier; // --- header: the wordmark at the left, the mark and name inside the panel --------------------------------- const tax = model.docType === "tax_invoice"; const titleW = Lw - 8; const tFit = fitSize(r.title, "Invoice", titleW); const titleH = leading(r.title) + (tax ? leading(r.taxCaption) + 2 : 0); const showName = !(logo && includesName) && s.name.trim() !== ""; const markW = logo?.w ?? 0; const nameLeft = v.head.panelInset + (markW > 0 ? markW + (showName ? v.head.markGap : 0) : 0); const nameW = showName ? Math.max(60, panelW - nameLeft - 4) : 0; const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0; const boxH = Math.max(logo?.h ?? 0, nameH); const headH = Math.max(titleH, boxH); // --- client block ------------------------------------------------------------------------------------------- const cw = Lw - v.client.inset - 8; let clientH = leading(r.billedLabel) + v.client.labelGap + textHeight(r.clientName, c.name, cw) + v.client.nameGap; for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, cw); clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, cw) * leading(r.clientLine); if (c.poNumber) clientH += textHeight(r.clientLine, `PO: ${c.poNumber}`, cw); // --- meta row ---------------------------------------------------------------------------------------------- const mt = v.meta; const hasDue = Boolean(model.meta.dueDate); const noLabel = `${model.meta.numberLabel.replace(/\.$/, "")} :`; const noW = Math.min(Lw - mt.inset - mt.dateMin, Math.max(mt.noMin, widthOf(r.metaLabel, noLabel) + 8, Math.ceil(measureToken(m, r.metaValue, model.meta.number)) + 10)); const dateW = round2(Lw - mt.inset - noW); const spacer = mt.panelInset; const dueW = mt.dueW; const amountW = round2(panelW - spacer - dueW); const metaH = leading(r.metaLabel) + mt.labelGap + leading(r.metaValue); // --- table ---------------------------------------------------------------------------------------------------- const t = v.table; const tableW = round2(W - t.left); const priced = model.items.map((i) => priceOf(i)); const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4; const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4; const qtyNeed = Math.ceil(Math.max(0, ...wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody).map((t) => measureToken(m, r.tableBody, t)), measureToken(m, r.headNum, "QTY"))) + 6; const amount = Math.max(t.cols.amount, amountNeed); const rate = Math.max(t.cols.rate, rateNeed); const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount)); const cwid = { qty: Math.max(qty, qtyNeed), rate, amount: round2(panelW - Math.max(qty, qtyNeed) - rate) }; const columns: ColSpec[] = [{ fill: true }, { fixed: cwid.qty }, { fixed: cwid.rate }, { fixed: cwid.amount }]; const headerCells: TableCellValue[] = [[{ text: "Service", role: r.headService }], [{ text: "Qty", role: r.headNum }], [{ text: "Rate", role: r.headNum }], [{ text: "Amount", role: r.headNum }]]; const align: CitrusPlan["align"] = ["left", "center", "right", "right"]; const padLeft = [t.bodyInset, 0, 0, 0]; const padRight = [t.descRight, 0, 14, 13.6]; const headPadY = (t.headH - leading(r.headNum)) / 2; const rows = tableRows(model, cwid.rate - 14 - 2, r.tableBody, r.tableSub); const planTable = (padY: number, tailH: number) => planItemsTable({ columns, gap: 0, width: tableW, header: headerCells, rows, measurer: m, roles: { head: r.headNum, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight }); // --- totals panel and the tail row ---------------------------------------------------------------------------- const tt = model.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 valueTexts = [tt.subtotalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)]; const innerTotals = panelW - v.totals.labelInset - v.totals.valueInset; const stackLabels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)]; const widestLabel = Math.ceil(Math.max(...stackLabels.map((l) => measureToken(m, r.totalLabel, l)))) + 4; // The amounts take their own width; the labels get the rest and never wrap (the row height is one line). const valueW = Math.min(innerTotals - widestLabel, Math.max(64, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2)); const barValueW = Math.max(valueW, Math.ceil(Math.max(measureToken(m, r.grandValue, tt.totalText), 0)) + 2); const stackRowH = leading(r.totalLabel) + v.totals.rowGap; const stackH = (stack.length + slotRows) * stackRowH; // Amount in words sits under the TOTAL bar (the design's tagline slot), right-aligned. const wordsW = panelW - 2 * 13.1; const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel }); const reverseH = slotHeights.reverseCharge(model, wordsW, r.small); const panelTail = v.separator.h + v.totals.sepGap + stackH + v.totals.barGap + v.totals.barH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0); const tailLeftW = Lw - t.left; const leftTail = v.totals.sepGap + clientH; // --- closing: method and terms at the left; words, contacts, signature in the panel column -------------------------- const cl = v.closing; const bank = bankLines(model); const bankH = model.bank ? leading(r.sectionLabel) + cl.labelGap + bank.reduce((h, l) => h + textHeight(r.bank, l, Lw - cl.bankInset - 8), 0) : 0; const terms = termsLines(model); const bulletW = Lw - cl.bulletText - 8; const termsH = terms.length ? leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, bulletW), 0) + (terms.length - 1) * cl.bulletGap : 0; const termsBeside = terms.length === 0 || termsH <= cl.termsBesideMax; const flowW = W - cl.methodInset - 8; const flowBulletW = flowW - (cl.bulletText - cl.methodInset); const flowH = termsBeside ? 0 : leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW), 0) + (terms.length - 1) * cl.bulletGap; const leftH = bankH + (termsBeside && bankH && termsH ? cl.sectionGap : 0) + (termsBeside ? termsH : 0); const contacts = contactEntries(model); const contactTextW = panelW - (cl.iconGap + cl.iconSize + cl.iconInset) - 8; const contactsH = contacts.reduce((h, x) => h + (x.id ? idLines(r.contact, x.lines[0], contactTextW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, contactTextW), 0)) + cl.contactGap, 0) - (contacts.length ? cl.contactGap : 0); const idLinesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small); const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.text, "right"); const signH = signatoryHeight(model, cl.signW, sOpts); const parts = [contactsH + (idLinesH ? 4 + idLinesH : 0), signH].filter((x) => x > 0); const rightH = parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 12; let closingH = cl.clientTop + Math.max(leftH, rightH) + 1.5; if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W); // --- gaps. The client block sits beside the totals when the invoice fits one page, above the table otherwise. ------------- const padYn = t.padY; const n = rows.length; const run = (clientAbove: boolean) => { const tailBody = Math.max(panelTail, clientAbove ? 0 : leftTail); const ranges = { client: clientAbove ? v.gaps.client : { nominal: 0, min: 0 }, meta: clientAbove ? { nominal: 24, min: 12 } : v.gaps.meta, table: v.gaps.table, tail: v.gaps.tail, rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n }, }; const table0 = planTable(padYn, 0); const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0); const nominalTotal = headH + (clientAbove ? ranges.client.nominal + clientH : 0) + ranges.meta.nominal + metaH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + (termsBeside ? 0 : NOTES_FLOW_GAP + flowH) + closingH; const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0); return { tailBody, ranges, nominalTotal, slack, tableTotal }; }; const faithful = run(false); const clientAbove = faithful.nominalTotal - faithful.slack > frame.contentHeight; const chosen = clientAbove ? run(true) : faithful; const { tailBody, ranges, nominalTotal, slack } = chosen; const overflow = nominalTotal - frame.contentHeight; const overflowBeforeClosing = nominalTotal - closingH - frame.contentHeight; const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0; const deficit = Math.max(frame.heightDeficit, fits); const g = elastic(ranges, deficit); const padY = n ? g.rowPad / (2 * n) : padYn; const tailH = g.tail + tailBody; const table = planTable(padY, tailH); const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (rows.length === 1 ? tailH : 0); return { kind: "citrus", v, W, Lw, panelW, clientAbove, head: { titleW, titleSize: tFit.size, tax, titleH, showName, nameW, nameLeft, nameH, boxH, h: headH }, client: { w: cw, h: clientH, nameW: cw }, meta: { noW, dateW, dueW, amountW, spacer, hasDue, h: metaH }, tableLeft: t.left, tableW, columns, headerCells, rows, align, padLeft, padRight, padY, headPadY, cw: cwid, table, guardH, tail: { leftW: tailLeftW, valueW, barValueW, stack, slotRows, stackRowH, stackH, panelH: panelTail, leftH: leftTail, body: tailBody, wordsW, wordsH, reverseH }, closing: { bank, terms, termsBeside, flowW, flowBulletW, flowH, leftH, bankH, termsH, bulletW, contacts, contactTextW, contactsH, idLinesH, signH, rightH, h: closingH }, gaps: { client: g.client, meta: g.meta, table: g.table, tail: g.tail }, tailH, closingH, }; }