import { colsDetailed, 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 { gridPadRight, 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 { bankLines, CANCELLED_SENTENCE, CLOSING_GAP, elastic, rateCell } from "../neutral/plan"; import type { LedgerVariant } from "./variant"; export const WORDS_GAP = 10; export const REVERSE_GAP = 4; /** Space between the closing block's heading and its content. */ export const HEADING_AFTER = 4; /** Space between the tail's left column (notes) and the totals box. */ export const TAIL_COLUMN_GAP = 16; const MIN_NAME_W = 40; /** Narrowest the title may be squeezed to share the row with a brand block. */ const MIN_BRAND_W = 90; const round2 = (n: number) => Math.round(n * 100) / 100; const leading = (t: TypeToken) => t.leading as number; export function metaLines(model: RenderModel): string[] { const label = model.meta.numberLabel === "Invoice No." ? "Invoice Number" : model.meta.numberLabel; const lines = [`${label}: ${model.meta.number}`, `Invoice Date: ${model.meta.date}`]; if (model.meta.dueDate) lines.push(`Due Date: ${model.meta.dueDate}`); if (model.client.poNumber) lines.push(`PO No: ${model.client.poNumber}`); return lines; } export function signatureOptions(v: LedgerVariant): SignatoryOptions { const r = v.roles; return { roles: { name: r.bodyStrong, designation: r.small, fine: r.fine }, align: "left", rule: { width: v.rule, color: v.ink }, color: v.ink, imageHeight: v.signatureImageH, }; } export interface TotalsRowSpec { label: string; amount: string; kind: "plain" | "total"; } export interface LedgerPlan { v: LedgerVariant; W: number; leftW: number; rightW: number; gaps: { rule: number; meta: number; party: number; table: number; tail: number; notes: number }; title: { token: TypeToken; w: number }; brand: { w: number; showName: boolean; nameW: number; nameH: number; /** Offsets inside the brand block (which starts at the zone's top). */ logoTop: number; nameTop: number; nameLeft: number; }; headerH: number; meta: string[]; columns: ColSpec[]; header: string[]; rows: TableCellValue[][]; align: Array<"left" | "center" | "right">; table: ItemsTablePlan; guardH: number; totals: { labelW: number; valueW: number; boxW: number; rowH: number; totalRowH: number; rows: TotalsRowSpec[]; slotRows: number; boxH: number }; notesBeside: boolean; notesW: number; wordsH: number; reverseH: number; tailH: number; closingH: number; payH: number; signH: number; signW: number; } /** * Everything the layout must know before rendering: the brand block, columns, the totals box and the * predicted heights of the totals tail and the closing block (their minPresenceAhead values), each a hair * high rather than low. */ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVariant, logo: PlacedLogo | null, includesName: boolean): LedgerPlan { const m = getMeasurer(); const r = v.roles; const W = frame.contentWidth; const hsn = Slot.HsnSac(model); // --- header: title and brand block ------------------------------------------------------------------ const markW = logo?.w ?? 0; const showName = !(logo && includesName) && model.supplier.name.trim() !== ""; const titleNatural = Math.ceil(measureToken(m, r.title, model.docTitle)) + 1; const brandCap = Math.floor(W * v.brandMaxShare); const minBrand = Math.max(markW, MIN_BRAND_W); const titleW = Math.min(titleNatural, W - v.brandGap - minBrand); const brandAvail = Math.min(brandCap, W - titleW - v.brandGap); const markBlock = markW > 0 && showName ? markW + v.markGap : markW; let nameW = 0; let nameH = 0; if (showName) { const natural = Math.ceil(measureToken(m, r.brand, model.supplier.name)) + 1; nameW = Math.max(MIN_NAME_W, Math.min(natural, brandAvail - markBlock)); nameH = textHeight(r.brand, model.supplier.name, nameW); } const brandW = markBlock + nameW; const zone = v.brandZone; const logoTop = logo ? (logo.slotOffset?.y ?? 0) : 0; const nameTop = logo ? Math.max(0, logoTop + (logo.h - nameH) / 2) : Math.max(0, (zone.h - nameH) / 2); const brandBottom = zone.top + Math.max(logo ? logoTop + logo.h : 0, showName ? nameTop + nameH : 0); const headerH = Math.max(v.titleTop + leading(r.title), zone.top + zone.h, brandBottom); // --- parties --------------------------------------------------------------------------------------- const leftW = round2(W * v.partyLeftShare); const rightW = round2(W - leftW - v.partyGap); const s = model.supplier; let supplierH = textHeight(r.label, s.name, leftW) + v.headingGap; for (const l of s.addressLines) supplierH += textHeight(r.body, l, leftW); supplierH += ([s.email, s.phone, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier")) * leading(r.body); const c = model.client; let clientH = leading(r.label) + v.headingGap + textHeight(r.body, c.name, rightW); for (const l of c.addressLines) clientH += textHeight(r.body, l, rightW); clientH += (slotHeights.gstinLines(model, "client") + 0) * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, rightW) * leading(r.body); const partyH = Math.max(supplierH, clientH); const meta = metaLines(model); const metaH = meta.length * leading(r.body) + (meta.length - 1) * v.metaGap; // --- table ------------------------------------------------------------------------------------------- const hsnCol = hsn; const ruleCols = (i: number) => (i === 0 ? 2 : 1) * v.rule; const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t))); const colW = (base: number, texts: string[], headText: string, idx: number) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1); const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—"; const nCols = hsnCol ? 5 : 4; const idx = (base: number) => (hsnCol ? base + 1 : base); const c0 = { hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0, rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit Price", idx(1)), qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", idx(2)), amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)), }; const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }]; const header = ["Item & Description", ...(hsnCol ? [hsnCol.header] : []), "Unit Price", "Qty", "Amount"]; const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"]; const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1)); const tableRows: TableCellValue[][] = model.items.map((item, i) => { const rate = rateCell(item, rateInner, r.tableBody); const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]); return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText]; }); void nCols; // --- totals box -------------------------------------------------------------------------------------- const t = model.totals; const labelTexts = ["Sub-Total", "Discount", "Taxable value", "UTGST @ 14%"]; const labelW = Math.max(v.totals.labelW, Math.ceil(Math.max(widest(r.totals, labelTexts), widest(r.total, ["Total"]))) + 2 * v.padX + 2 * v.rule + 1); const amounts = [t.subtotalText, t.totalText, t.discountText ? `- ${t.discountText}` : "", t.taxableText ?? ""]; const valueW = Math.max(v.totals.valueW, Math.ceil(Math.max(widest(r.totals, amounts), widest(r.total, [t.totalText]))) + 2 * v.padX + v.rule + 1); const boxW = Math.min(labelW + valueW, Math.floor(W * v.totals.maxShare) + 40); const boxRows: TotalsRowSpec[] = [{ label: "Sub-Total", amount: t.subtotalText, kind: "plain" }]; if (t.discountText) boxRows.push({ label: "Discount", amount: `- ${t.discountText}`, kind: "plain" }); if (t.taxableText) boxRows.push({ label: "Taxable value", amount: t.taxableText, kind: "plain" }); const slotRows = slotHeights.taxRowCount(model); const rowH = leading(r.totals) + 2 * v.totals.padY + v.rule; const totalRowH = leading(r.total) + 2 * v.totals.padY + v.rule; const boxH = (boxRows.length + slotRows) * rowH + totalRowH + v.rule; // --- tail: notes beside the box, then the words ------------------------------------------------------ const notesW = Math.max(0, W - boxW - TAIL_COLUMN_GAP); const notesH = model.notesLines.length ? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, notesW), 0) : 0; const notesBeside = notesH > 0 && notesW >= 140 && notesH <= v.notesBesideMax; const wordsH = slotHeights.amountInWords(model, W, { role: r.small, labelRole: r.wordsLabel }); const reverseH = slotHeights.reverseCharge(model, W, r.small); const tailBody = (notesBeside ? Math.max(boxH, notesH) : boxH) + 1 + (wordsH ? WORDS_GAP + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0); // --- closing ----------------------------------------------------------------------------------------- const signW = Math.min(v.signatureW, rightW); const lines = bankLines(model); let payH = 0; if (model.bank) { payH = leading(r.label) + HEADING_AFTER; for (const l of lines) payH += textHeight(r.small, l, leftW); } const signH = leading(r.label) + HEADING_AFTER + signatoryHeight(model, signW, signatureOptions(v)); let closingH = CLOSING_GAP + Math.max(payH, signH) + 1.5; if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W); // --- elastic gaps ------------------------------------------------------------------------------------ const ranges = { rule: v.headerRule ? v.gaps.rule : { nominal: 0, min: 0 }, meta: v.gaps.meta, party: v.gaps.party, table: v.gaps.table, tail: v.gaps.tail, notes: v.gaps.notes }; const nominalTail = ranges.tail.nominal + tailBody; const table0 = planTable(model, v, W, columns, header, tableRows, nCols, frame, nominalTail); const tableTotal = table0.headH + table0.rowHeights.reduce((a, b) => a + b, 0); const ownNotes = model.notesLines.length > 0 && !notesBeside; const ownNotesH = ownNotes ? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, W), 0) : 0; const nominalTotal = headerH + ranges.rule.nominal + (v.headerRule?.thickness ?? 0) + ranges.meta.nominal + metaH + ranges.party.nominal + partyH + ranges.table.nominal + tableTotal + nominalTail + (ownNotes ? ranges.notes.nominal + ownNotesH : 0) + closingH; const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0); const overflow = nominalTotal - frame.contentHeight; const deficit = Math.max(frame.heightDeficit, overflow > 0 && overflow <= slack ? overflow : 0); const g = elastic(ranges, deficit); const tailH = g.tail + tailBody; const table = planTable(model, v, W, columns, header, tableRows, nCols, frame, tailH); // Rows are unbreakable, so the guard asks for the whole first row (the shared 3-line cap leaves a header alone // above a taller row); only a row beyond 40% of the page is capped, so it cannot push the table off every page. // A one-row table has no previous row to carry the last row's own presence value (the row is first in its // container, where react-pdf never breaks before it), so the guard also holds the tail. const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (tableRows.length === 1 ? tailH : 0); return { v, W, leftW, rightW, gaps: { rule: g.rule, meta: g.meta, party: g.party, table: g.table, tail: g.tail, notes: g.notes }, title: { token: r.title, w: titleW }, brand: { w: brandW, showName, nameW, nameH, logoTop, nameTop, nameLeft: markBlock }, headerH, meta, columns, header, rows: tableRows, align, table, guardH, totals: { labelW, valueW, boxW, rowH, totalRowH, rows: boxRows, slotRows, boxH }, notesBeside, notesW, wordsH, reverseH, tailH, closingH, payH, signH, signW, }; } function planTable( model: RenderModel, v: LedgerVariant, W: number, columns: ColSpec[], header: string[], rows: TableCellValue[][], nCols: number, frame: PageFrame, tailH: number, ): ItemsTablePlan { const r = v.roles; void model; return planItemsTable({ columns, gap: 0, width: W, header, rows, measurer: getMeasurer(), roles: { head: r.tableHead, body: r.tableBody }, padX: v.padX, padY: v.padY, border: v.rule, padRight: gridPadRight(nCols, v.rule), // The header's rule above its cells. headerTailH: v.rule, tailH, contentH: frame.contentHeight, }); } export { colsDetailed, textLines };