Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
306 lines
18 KiB
TypeScript
306 lines
18 KiB
TypeScript
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<string, string> = { 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,
|
|
};
|
|
}
|