When only the closing block moved to page 2, the page-1 deficit was shared across every gap, including the closing block's own gaps, which cannot help page 1. Page 1 then came out a few points short and the totals spilled over with the closing block. Monolith and Cobalt Stripe now squeeze only the page-1 gaps in that case, as Marble and Highlighter already do. Monolith's flow notes block could start in the last few points of a page and be cut into zero-height lines. It now holds 70 pt of room under its label first, as Marble does. Adds tests for both cases (they failed before the fix) and notes in the template specs. Fingerprint goldens and thumbnails are unchanged.
280 lines
16 KiB
TypeScript
280 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|