import type { ColSpec } from "../../engine/columns"; import type { PageFrame } from "../../engine/geometry"; import { drawnText, getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure"; import { textHeight, textLines } 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 { resolveFace, type TypeToken } from "../../fonts/roles"; import type { PlacedLogo } from "../contract"; import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan"; import { leading, INDIGO, type HighlighterVariant } 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; export const LABEL_GAP = 3; /** 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))); } /** Tax rows read "CGST @ 9% :" like the design's "total :". */ export const taxLabel = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate} :` : `${label} :`); export function signatureOptions(v: HighlighterVariant): SignatoryOptions { const r = v.roles; return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.hair, color: INDIGO }, color: v.ink, imageHeight: v.closing.signatureImageH }; } /** The table's description (+ HSN/SAC sub-line), qty, rate and amount cells. */ export function tableRows(model: RenderModel, v: HighlighterVariant, 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 [desc, item.qtyText, rateParts, item.amountText]; }); } /** The payment block: "Bank Name : ...", one bold line per bank field (the design's "label : value" pairs). */ export function paymentLines(model: RenderModel): string[] { if (!model.bank) return []; const names: Record = { Bank: "Bank Name", "Account No.": "Account No" }; return [`Account Name : ${model.bank.accountName}`, ...model.bank.lines.map((l) => `${names[l.label] ?? l.label} : ${l.value}`)]; } export interface FieldSpec { label: string; value: string; } /** The underlined fields in the design's order (Date, Invoice number), plus the Due date, then Billed to. */ export function fieldSpecs(model: RenderModel): FieldSpec[] { const out: FieldSpec[] = [ { label: "Date:", value: model.meta.date }, { label: "Invoice number:", value: model.meta.number }, ]; if (model.meta.dueDate) out.push({ label: "Due date:", value: model.meta.dueDate }); out.push({ label: "Billed to:", value: model.client.name }); return out; } /** The note lines wrapped to the ruled lines' width: one string per drawn line. */ export function ruledNoteLines(model: RenderModel, role: TypeToken, width: number): string[] { const m = getMeasurer(); return model.notesLines.flatMap((n) => m.wrapLines(drawnText(role, n), width, { face: resolveFace(role.face), weight: role.weight, italic: false, size: role.size, letterSpacing: (role.tracking ?? 0) * role.size })); } /** The contact rows of the sign-off, with the address as one wrapping line. */ export function contactRows(model: RenderModel): Array<{ icon: "phone" | "email" | "location"; text: string }> { const s = model.supplier; const out: Array<{ icon: "phone" | "email" | "location"; text: string }> = []; if (s.phone) out.push({ icon: "phone", text: s.phone }); if (s.email) out.push({ icon: "email", text: s.email }); const address = s.addressLines.join(", "); if (address.trim()) out.push({ icon: "location", text: address }); return out; } export interface HighlighterPlan { v: HighlighterVariant; W: number; head: { titleSize: number; titleW: number; stackW: number; showName: boolean; nameH: number; idsH: number; stackH: number; h: number }; fields: { specs: FieldSpec[]; labelW: number[]; valueW: number; heights: number[]; fieldsH: number; subLines: number; subH: number; leftW: number; payLines: string[]; payH: 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 }; innerW: number; table: ItemsTablePlan; padRows: number; padRowH: number; groupH: number; guardH: number; tail: { boxW: number; labelW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rows: number; boxH: number; wordsW: number; wordsH: number; reverseH: number; leftH: number; body: number }; closing: { notes: string[]; ruledRows: number; ruled: string[]; notesFlow: boolean; notesFlowH: number; leftW: number; contacts: Array<{ icon: "phone" | "email" | "location"; text: string; h: number }>; leftH: number; rightH: number; signH: number; h: number }; gaps: { bar: number; close: number }; tailH: number; closingH: number; } /** * Everything the layout must know before rendering: the title fitted to its zone, the underlined fields and the payment * block, the table (its columns widen only where an amount needs it; the form look pads to 8 empty rows on A4 and 7 on Letter * while they fit), the totals stack and the heights of the tail and the closing block, each a hair high rather than low. */ export function planHighlighter(model: RenderModel, frame: PageFrame, v: HighlighterVariant, logo: PlacedLogo | null, includesName: boolean): HighlighterPlan { const m = getMeasurer(); const r = v.roles; const W = frame.contentWidth; const s = model.supplier; const c = model.client; const hd = v.head; const fr = v.frame; // --- header: the title at the left, the vendor's logo, name and identifiers at the right -------------------------------------- const stackW = Math.min(hd.stackW, Math.max(80, W - hd.minTitleW - hd.gap - v.inset - v.rightInset)); const titleW = Math.max(hd.minTitleW, W - stackW - v.rightInset - hd.gap - v.inset); const tFit = fitSize(r.title, model.docTitle, titleW); const showName = !(logo && includesName) && s.name.trim() !== ""; const nameH = showName ? textHeight(r.vendorName, s.name, stackW) : 0; const gstinLines = slotHeights.gstinLines(model, "supplier"); const idsH = (gstinLines ? idLines(r.vendorId, GSTIN_SAMPLE, stackW) * leading(r.vendorId) : 0) + (s.pan ? idLines(r.vendorId, `PAN: ${s.pan}`, stackW) * leading(r.vendorId) : 0); const cancelH = model.cancelled ? leading(r.vendorName) : 0; const stackH = (logo ? logo.h : 0) + nameH + idsH + cancelH; const headH = Math.max(hd.zoneH, stackH); // --- fields (underlined) and the payment block --------------------------------------------------------------------------------- const specs = fieldSpecs(model); const f = v.fields; const fieldLabelW = specs.map((sp) => Math.ceil(measureToken(m, r.label, sp.label)) + 1); const valueWs = fieldLabelW.map((lw) => f.w - lw - f.labelGap); const heights = specs.map((sp, i) => textLines(r.value, sp.value, valueWs[i] - f.valuePad) * leading(r.value) + v.hair); const fieldsRows = heights.reduce((a, h) => a + h, 0) + f.rowGap * (specs.length - 1); const leftW = W - v.pay.w - v.rightInset; const subW = f.w; let subH = 0; for (const l of c.addressLines) subH += textHeight(r.sub, l, subW); subH += slotHeights.gstinLines(model, "client") * leading(r.sub) + slotHeights.placeOfSupplyLines(model, r.sub, subW) * leading(r.sub); if (c.poNumber) subH += textHeight(r.sub, `PO: ${c.poNumber}`, subW); const fieldsH = fieldsRows + (subH > 0 ? f.subGap + subH : 0); const payLines = paymentLines(model); const payW = Math.min(v.pay.w, W); const payH = payLines.length ? v.pay.top + leading(r.payHead) + payLines.reduce((h, x) => h + textHeight(r.payLine, x, payW), 0) : 0; const blocksH = Math.max(fieldsH, payH); // --- table ----------------------------------------------------------------------------------------------------------------- const t = v.table; const innerW = W - 2 * fr; const rateOnly = model.items.map((i) => (i.priceText ?? "—")); 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 + v.hair + 6; const shortRate = model.items.map((i) => { const cell = rateCell(i, 10000, r.tableBody); return typeof cell === "string" ? cell : "—"; }); const cw = { qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)), rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))), amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair), }; const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }]; const headerCells = ["Description", "Qty", "Price", "Total"]; const align: HighlighterPlan["align"] = ["left", "center", "right", "right"]; // Each cell but the last ends in a 0.75 pt rule that is part of its width. const padLeft = [t.descPad, 0, 0, 0]; const padRight = [8 + v.hair, v.hair, t.numPad + v.hair, t.numPad]; const rows = tableRows(model, v, cw.rate - t.numPad - v.hair - 2); const n = rows.length; const baseLines = leading(r.tableBody); const padYn = (t.rowH - v.hair - baseLines) / 2; const planTable = (padY: number, tailH: number) => planItemsTable({ columns, gap: 0, width: innerW, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY: 0, border: v.hair, headBorder: 0, tailH, contentH: frame.contentHeight }); const groupH = t.barH + t.bodyGap + fr; // The last row's bottom edge is the frame (3 pt) instead of a hairline. const lastExtra = fr - v.hair; // --- totals: a stack of two-cell rows, the Total row's label cell yellow; 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), "total :"]; const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)]; const labelNeed = Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)))) + 2 * o.labelPad + fr; const valueNeed = Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)), measureToken(m, r.totalStrong, tt.totalText))) + o.valuePad + o.labelPad + 2; // The box lines up with the table's columns: its label cell is the price column wide, its value cell the rest (the design's // box starts 1.5 pt outside the price column's left rule). const boxW0 = fr + cw.amount + cw.rate + o.boxOutside; const labelW = Math.max(cw.rate, labelNeed); const valueW = Math.max(boxW0 - 2 * fr - cw.rate, valueNeed); const boxW = labelW + valueW + 2 * fr; const totalRows = stack.length + slotRows + 1; const boxH = 2 * fr + totalRows * o.rowH + (totalRows - 1) * v.hair; const tailLeft = Math.max(0, W - boxW); const wordsW = Math.max(60, tailLeft - o.wordsGap - v.inset); const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel }); const reverseH = slotHeights.reverseCharge(model, wordsW, r.words); const leftH = wordsH || reverseH ? o.wordsTop + wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0; const body = Math.max(boxH, leftH); const tailH = o.gap + body; // --- closing: THANK YOU and the contact rows at the left, the ruled lines (notes, then the signature) at the right ----------- const cl = v.closing; const closeLeftW = W - v.rightInset - cl.ruledW - cl.colGap; const textW = Math.max(60, closeLeftW - v.inset - cl.iconSize - cl.iconGap); const contacts = contactRows(model).map((x) => ({ ...x, h: Math.max(cl.iconSize, (x.icon === "location" ? textLines(r.contact, x.text, textW) : idLines(r.contact, x.text, textW)) * leading(r.contact)) })); const leftContentH = leading(r.thanks) + (contacts.length ? cl.thanksGap + contacts.reduce((h, x) => h + x.h, 0) + cl.contactGap * (contacts.length - 1) : 0); const leftH2 = cl.leftTop + leftContentH; const ruled = ruledNoteLines(model, r.note, cl.ruledW - 2); const notesFlow = ruled.length > cl.ruledMax; const ruledRows = notesFlow ? cl.ruledMin : Math.max(cl.ruledMin, ruled.length); const signH = signatoryHeight(model, cl.ruledW, signatureOptions(v)); const rightH = ruledRows * cl.ruledH + signH; const notesFlowH = notesFlow ? leading(r.wordsLabel) + LABEL_GAP + model.notesLines.reduce((h, l) => h + textHeight(r.note, l, W - v.inset - v.rightInset), 0) : 0; const cancelledH = model.cancelled ? 10 + textHeight(r.note, CANCELLED_SENTENCE, W) : 0; const bandH = Math.max(leftH2, rightH); const closingFor = (g: { close: number }) => g.close + bandH + cancelledH; // --- pad rows, gaps: the form look pads to N rows while the page holds them; the gaps and row padding give back Letter's height ----- const gapBarNominal = Math.max(v.gaps.bar.nominal, f.designH - blocksH); const ranges0 = { bar: { nominal: gapBarNominal, min: v.gaps.bar.min }, close: v.gaps.close }; const table0 = planTable(padYn, 0); const dataH = table0.rowHeights.reduce((a, x) => a + x, 0); const padRowH = t.rowH; const notesBlock = notesFlow ? NOTES_FLOW_GAP + notesFlowH : 0; const headTop = headH + f.top; const nominalNoClosing = (pad: number) => headTop + ranges0.bar.nominal + blocksH + groupH + dataH + pad * padRowH + lastExtra + tailH + notesBlock; let padRows = Math.max(0, t.padTo[frame.page.id] - n); const rowPadRange = (pad: number) => ({ nominal: 2 * padYn * (n + pad), min: 2 * t.padYMin * (n + pad) }); // What the gaps can give back while the table and totals stay on page 1 (the sign-off's own gap does not help them). const slackBefore = (pad: number) => ranges0.bar.nominal - ranges0.bar.min + rowPadRange(pad).nominal - rowPadRange(pad).min; const closeNominal = ranges0.close.nominal; const closeSlack = ranges0.close.nominal - ranges0.close.min; const closingAt = closingFor({ close: closeNominal }); // The pad rows are decoration, so they give way first: the most of them that still lets the whole invoice (sign-off included) // sit on one page; failing that, the most that lets the table and totals sit on page 1 with only the sign-off moving on. const wanted = padRows; const fitsAll = (pad: number) => nominalNoClosing(pad) + closingAt - frame.contentHeight <= slackBefore(pad) + closeSlack; const fitsBefore = (pad: number) => nominalNoClosing(pad) - frame.contentHeight <= slackBefore(pad); while (padRows > 0 && !fitsAll(padRows)) padRows--; if (!fitsAll(padRows)) { padRows = wanted; while (padRows > 0 && !fitsBefore(padRows)) padRows--; } const ranges = { ...ranges0, rowPad: rowPadRange(padRows) }; const closingNominal = closingFor({ close: ranges.close.nominal }); const nominalTotal = nominalNoClosing(padRows) + closingNominal; const slackAll = slackBefore(padRows) + ranges.close.nominal - ranges.close.min; const overflow = nominalTotal - frame.contentHeight; const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight; const air = (over: number, slack: number) => Math.min(slack, over + FIT_AIR); // 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 (then its own gap stays nominal). const { close: closeRange, ...beforeRanges } = ranges; let g: Record<"bar" | "rowPad" | "close", number>; if (overflow > 0 && overflow <= slackAll) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slackAll))); else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore(padRows)) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore(padRows)))), close: closeRange.nominal }; else g = elastic(ranges, frame.heightDeficit); const rowCount = n + padRows; const padY = rowCount ? g.rowPad / (2 * rowCount) : padYn; const closingH = closingFor({ close: g.close }); const tailHigh = o.gap + body; const table = planTable(padY, tailHigh + lastExtra); const guardH = groupH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailHigh : 0); return { v, W, head: { titleSize: tFit.size, titleW, stackW, showName, nameH, idsH, stackH, h: headH }, fields: { specs, labelW: fieldLabelW, valueW: f.w, heights, fieldsH, subLines: subH > 0 ? 1 : 0, subH, leftW, payLines, payH, h: blocksH }, columns, headerCells, rows, align, padLeft, padRight, padY, cw, innerW, table, padRows, padRowH: baseLines + 2 * padY + v.hair, groupH, guardH, tail: { boxW, labelW, valueW, stack, slotRows, rows: totalRows, boxH, wordsW, wordsH, reverseH, leftH, body }, closing: { notes: model.notesLines, ruledRows, ruled, notesFlow, notesFlowH, leftW: closeLeftW, contacts, leftH: leftH2, rightH, signH, h: closingH }, gaps: { bar: g.bar, close: g.close }, tailH: tailHigh, closingH, }; }