Files
Voiced/src/pdf/templates/cobalt/plan.ts
T
xavierk 3aa0d33a01 Add units of measure and fractional quantities to rate lines
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
2026-10-06 09:35:20 +05:30

368 lines
20 KiB
TypeScript

import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { innerRulePadRight, 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 { INK, leading, type CobaltVariant } 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";
/** Narrowest the footer address column may get before the phone and e-mail shrink to make room. */
const MIN_ADDRESS_W = 120;
/** Space between a mark and the tagline under a wordmark logo. */
const WORDMARK_TAG_GAP = 3;
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
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);
}
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1;
export interface MetaLine {
strong: boolean;
text: string;
cancelled?: boolean;
}
export function metaLines(model: RenderModel): MetaLine[] {
const out: MetaLine[] = [...(model.cancelled ? [{ strong: true, text: "CANCELLED", cancelled: true }] : []), { strong: true, text: `${model.meta.numberLabel.replace(/\.$/, "")} : ${model.meta.number}` }, { strong: false, text: model.meta.date }];
if (model.meta.dueDate) out.push({ strong: false, text: `Due date : ${model.meta.dueDate}` });
if (model.client.poNumber) out.push({ strong: false, text: `PO : ${model.client.poNumber}` });
return out;
}
/** The bank block's lines: "Label : value", the design's "Bank Name : ..." and "Account Number : ...". */
export function bankLines(model: RenderModel): string[] {
const bank = model.bank;
if (!bank) return [];
const rename: Record<string, string> = { Bank: "Bank Name", "Account No.": "Account Number" };
const out = bank.lines.map((l) => `${rename[l.label] ?? l.label} : ${l.value}`);
const at = bank.lines.findIndex((l) => l.label === "Bank") + 1;
out.splice(at, 0, `Account Name : ${bank.accountName}`);
return out;
}
export function signatureOptions(v: CobaltVariant): SignatoryOptions {
const r = v.roles;
return { roles: { name: r.signName, designation: r.signTitle, fine: r.fine }, align: "right", rule: null, color: INK, imageHeight: v.closing.signatureImageH };
}
/** The table's no., description (+ HSN/SAC sub-line), qty, price and amount cells. */
export function tableRows(model: RenderModel, v: CobaltVariant, 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 [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
});
}
export interface ContactItem {
icon: "phone" | "email" | "location";
lines: string[];
/** Identifiers (phone, e-mail) shrink instead of wrapping; the address wraps. */
id: boolean;
/** Width of the text box beside the icon. */
textW: number;
h: number;
}
export interface CobaltPlan {
v: CobaltVariant;
W: number;
head: {
titleSize: number;
titleW: number;
leftW: number;
mode: "name" | "wordmark" | "none";
showName: boolean;
markW: number;
nameW: number;
nameH: number;
tagline: boolean;
taglineH: number;
leftH: number;
h: number;
ruleGap: number;
ruleText: string;
ruleTextW: number;
ruleW: number;
ruleH: number;
};
info: { metaW: number; clientW: number; lines: MetaLine[]; metaH: number; clientH: number; h: number };
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
headAlign: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
headPadY: number;
cw: { no: number; qty: number; price: number; amount: number; priceInset: number };
table: ItemsTablePlan;
guardH: number;
tail: {
stack: Array<{ label: string; amount: string }>;
slotRows: number;
stackH: number;
rightW: number;
valueW: number;
leftW: number;
leftTop: number;
/** The PAYMENT METHOD bar is level with the GRAND TOTAL bar (the design) rather than beside the first totals row. */
aligned: boolean;
bank: string[];
bankH: number;
wordsH: number;
reverseH: number;
rightH: number;
leftH: number;
body: number;
};
closing: {
leftW: number;
signCol: number;
terms: string[];
termsBeside: boolean;
termsH: number;
flowW: number;
flowH: number;
leftH: number;
signH: number;
rowH: number;
contacts: ContactItem[];
contactGap: number;
contactsW: number;
contactsH: number;
footerH: number;
h: number;
};
gaps: { client: number; table: number; tail: number; terms: number; footer: number };
tailH: number;
closingH: number;
}
function planContacts(model: RenderModel, v: CobaltVariant, W: number): { items: ContactItem[]; gap: number; width: number; h: number } {
const s = model.supplier;
const r = v.roles;
const c = v.closing;
const raw: Array<Pick<ContactItem, "icon" | "lines" | "id">> = [];
if (s.phone) raw.push({ icon: "phone", lines: [s.phone], id: true });
if (s.email) raw.push({ icon: "email", lines: [s.email], id: true });
if (s.addressLines.length) raw.push({ icon: "location", lines: s.addressLines, id: false });
const n = raw.length;
if (n === 0) return { items: [], gap: 0, width: 0, h: 0 };
const natural = raw.map((x) => Math.max(...x.lines.map((l) => widthOf(r.contact, l))));
const iconBlock = c.icon.size + c.icon.gap;
const budget = W - n * iconBlock - (n - 1) * c.minContactGap;
let texts = natural;
if (natural.reduce((a, b) => a + b, 0) > budget) {
// Identifiers keep their natural width while the address (which wraps) keeps a usable column; otherwise they shrink
// (to their minimum size, then chunk) to a share of the row and the address wraps in what is left.
const ids = raw.reduce((a, x, i) => a + (x.id ? natural[i] : 0), 0);
const free = raw.filter((x) => !x.id).length;
const room = free ? (budget - ids) / free : 0;
const cap = free === 0 ? Math.floor(budget / n) : room >= MIN_ADDRESS_W ? Infinity : Math.floor(budget * 0.3);
const idW = raw.map((x, i) => (x.id ? Math.min(natural[i], cap) : 0));
const rest = budget - idW.reduce((a, b) => a + b, 0);
texts = raw.map((x, i) => (x.id ? idW[i] : Math.max(60, Math.floor(rest / Math.max(1, free)))));
}
const used = texts.reduce((a, b) => a + b, 0) + n * iconBlock;
const gap = n > 1 ? Math.max(c.minContactGap, Math.min(c.contactGap, (W - used) / (n - 1))) : 0;
const items = raw.map((x, i): ContactItem => {
const textW = texts[i];
const h = x.id ? idLines(r.contact, x.lines[0], textW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, textW), 0);
return { ...x, textW, h };
});
const width = used + (n - 1) * gap;
return { items, gap, width, h: Math.max(c.icon.size, ...items.map((x) => x.h + Math.max(0, (c.icon.size - leading(r.contact)) / 2))) };
}
/**
* Everything the layout must know before rendering: the header (the INVOICE title's size and the left block's mode),
* the info row, the five table columns, the totals' two columns, the closing block and the footer's contact row, and
* the heights of the tail and the closing block (their minPresenceAhead values), each a hair high rather than low.
*/
export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVariant, logo: PlacedLogo | null, includesName: boolean): CobaltPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const s = model.supplier;
const c = model.client;
const h = v.head;
// --- header ------------------------------------------------------------------------------------------------------------
const tFit = fitSize(r.title, model.docTitle, Math.floor(W * 0.62));
const titleW = Math.ceil(tFit.width) + 2;
const leftW = W - titleW - h.titleGap;
const showName = !(logo && includesName) && s.name.trim() !== "";
const mode: "name" | "wordmark" | "none" = showName ? "name" : logo ? "wordmark" : "none";
const markW = logo && showName ? logo.w : 0;
const nameW = showName ? Math.max(h.minNameW, leftW - (markW ? markW + h.markGap : 0)) : 0;
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
const tagline = slotHeights.gstinLines(model, "supplier") > 0;
const tagW = mode === "name" ? nameW : leftW;
const taglineH = tagline ? idLines(r.tagline, GSTIN_SAMPLE, tagW) * leading(r.tagline) : 0;
let headLeftH = 0;
if (mode === "name") headLeftH = Math.max(h.nameTop + nameH + (tagline ? h.taglineGap + taglineH : 0), logo ? h.logoTop + logo.h : 0);
else if (mode === "wordmark") headLeftH = h.logoTop + (logo?.h ?? 0) + (tagline ? WORDMARK_TAG_GAP + taglineH : 0);
const headH = Math.max(headLeftH, leading(r.title));
const ruleText = s.pan ? `PAN: ${s.pan}` : "";
const ruleH = leading(r.ruleText);
const ruleTextW = ruleText ? Math.min(widthOf(r.ruleText, ruleText), Math.floor(W * 0.38)) : 0;
const ruleW = W - (ruleTextW ? ruleTextW + h.ruleTextGap : 0);
const ruleGap = Math.max(h.ruleMinGap, h.ruleTop - headH);
// --- info row: Billed to at the left, the invoice numbers at the right --------------------------------------------------
const lines = metaLines(model);
const metaW = Math.min(Math.floor(W * v.info.metaMaxShare), Math.max(v.info.metaMinW, ...lines.map((l) => widthOf(l.strong ? r.metaStrong : r.metaLine, l.text))));
const clientW = W - metaW - v.info.clientGap;
let clientH = leading(r.label) + textHeight(r.clientName, c.name, clientW);
const clientLines = c.addressLines.length + slotHeights.gstinLines(model, "client") + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW);
if (clientLines > 0) clientH += v.info.nameGap;
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, clientW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW) * leading(r.clientLine);
const metaH = lines.reduce((a, l) => a + idLines(l.strong ? r.metaStrong : r.metaLine, l.text, metaW) * leading(r.metaLine), 0);
const infoH = Math.max(clientH, metaH);
// --- table: NO | DESCRIPTION | QTY | PRICE | TOTAL ---------------------------------------------------------------------------
const t = v.table;
const rule = t.separator;
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
const priced = model.items.map((i) => priceOf(i));
const amountTexts = model.items.map((i) => i.amountText);
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
const qty = Math.max(t.cols.qty, widest(r.tableBody, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 12 + rule);
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
const price = Math.max(t.cols.price, priceNeed);
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
const amount = Math.max(t.cols.amount, Math.max(widest(r.tableBody, amountTexts), widest(r.tableHead, ["Total"])) + t.amountInset + 8);
const columns: ColSpec[] = [{ fixed: no }, { fill: true }, { fixed: qty }, { fixed: price }, { fixed: amount }];
const headerCells = ["No", "Description", "Qty", "Price", "Total"];
const align: CobaltPlan["align"] = ["center", "left", "center", "right", "right"];
const headAlign: CobaltPlan["headAlign"] = ["center", "center", "center", "right", "center"];
const padLeft = [0, t.descInset, 0, 0, 0];
// The inner rule counts toward the inset, so the price ends `priceInset` short of its column edge as in the design.
const padRight = [0, 6, 0, priceInset - rule, t.amountInset];
const planPadRight = padRight.map((p, i) => p + innerRulePadRight(5, rule)[i]);
const headPadY = (t.headH - leading(r.tableHead)) / 2;
const rows = tableRows(model, v, price - rule - padRight[3] - 2);
const n = rows.length;
const padY = t.padY;
const planTable = (tailH: number) =>
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight: planPadRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight });
// --- totals: Sub Total rows and the GRAND TOTAL bar at the right, the PAYMENT METHOD bar and the bank lines at the left ------
const tt = model.totals;
const o = v.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: labelWithColon("Sub Total"), amount: tt.subtotalText }];
if (tt.discountText) stack.push({ label: labelWithColon("Discount"), amount: `- ${tt.discountText}` });
if (tt.taxableText) stack.push({ label: labelWithColon("Taxable Value"), amount: tt.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const stackH = (stack.length + slotRows) * leading(r.totalLabel);
const valueW = o.rightW - o.labelW;
const wordsH = slotHeights.amountInWords(model, o.rightW, { role: r.words, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, o.rightW, r.words);
const rightH = stackH + o.barGap + o.barH + (wordsH ? o.wordsGap + wordsH : 0) + (reverseH ? o.reverseGap + reverseH : 0);
const tailLeftW = W - o.rightW - o.leftGap;
const bank = bankLines(model);
const bankH = bank.reduce((a, l) => a + textHeight(r.bank, l, tailLeftW - o.barInset), 0);
const leftBlockH = o.barH + o.bankGap + bankH;
const leftHFor = (top: number) => (model.bank ? top + leftBlockH : 0);
// --- closing ------------------------------------------------------------------------------------------------------------------
const cl = v.closing;
const signCol = cl.signW + cl.signInset;
const cLeftW = W - signCol - 8;
const terms = model.notesLines;
const termsH = terms.length ? leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, cLeftW), 0) : 0;
const termsBeside = terms.length === 0 || termsH <= cl.notesBesideMax;
const flowW = W - 2;
const flowH = termsBeside ? 0 : leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, flowW), 0);
const signH = signatoryHeight(model, cl.signW, signatureOptions(v));
const contacts = planContacts(model, v, W);
const footerRuleH = h.rule.accent;
const footerH = footerRuleH + (contacts.items.length ? cl.ruleToContacts + contacts.h : 0);
const leftBase = cl.divider.thickness + cl.dividerGap + cl.thanksH;
const cancelledH = model.cancelled ? 10 + textHeight(r.fine, CANCELLED_SENTENCE, W) : 0;
const closingFor = (g: { terms: number; footer: number }) => {
const leftHh = leftBase + (termsBeside && terms.length ? g.terms + termsH : 0);
const rowH = Math.max(leftHh, signH);
return { leftH: leftHh, rowH, h: rowH + g.footer + footerH + cancelledH };
};
// --- gaps -----------------------------------------------------------------------------------------------------------------------
const ranges = { client: v.gaps.client, table: v.gaps.table, tail: v.gaps.tail, terms: termsBeside && terms.length ? v.gaps.terms : { nominal: 0, min: 0 }, footer: v.gaps.footer };
const nominalClosing = closingFor({ terms: ranges.terms.nominal, footer: ranges.footer.nominal });
const table0 = planTable(0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const notesBlock = termsBeside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalFor = (tailBody: number) => headH + ruleGap + ruleH + ranges.client.nominal + infoH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + notesBlock + nominalClosing.h;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
// The PAYMENT METHOD bar sits level with the GRAND TOTAL bar, as in the design, whenever the invoice still fits its page
// that way; with more totals rows than the design's two it moves up beside the first row, which saves up to the stack's height.
const alignedTop = stackH + o.barGap;
const aligned = nominalFor(Math.max(rightH, leftHFor(alignedTop))) - slack <= frame.contentHeight || !model.bank;
const leftTop = aligned ? alignedTop : 0;
const leftH = leftHFor(leftTop);
const tailBody = Math.max(rightH, leftH);
const nominalTotal = nominalFor(tailBody);
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 - nominalClosing.h - frame.contentHeight;
const { terms: termsRange, footer: footerRange, ...beforeRanges } = ranges;
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
// 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<"client" | "table" | "tail" | "terms" | "footer", 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))), terms: termsRange.nominal, footer: footerRange.nominal };
else g = elastic(ranges, frame.heightDeficit);
const closing = closingFor(g);
const tailH = g.tail + tailBody;
const table = planTable(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, leftW, mode, showName, markW, nameW, nameH, tagline, taglineH, leftH: headLeftH, h: headH, ruleGap, ruleText, ruleTextW, ruleW, ruleH },
info: { metaW, clientW, lines, metaH, clientH, h: infoH },
columns,
headerCells,
rows,
align,
headAlign,
padLeft,
padRight,
padY,
headPadY,
cw: { no, qty, price, amount, priceInset },
table,
guardH,
tail: { stack, slotRows, stackH, rightW: o.rightW, valueW, leftW: tailLeftW, leftTop, aligned, bank, bankH, wordsH, reverseH, rightH, leftH, body: tailBody },
closing: { leftW: cLeftW, signCol, terms, termsBeside, termsH, flowW, flowH, leftH: closing.leftH, signH, rowH: closing.rowH, contacts: contacts.items, contactGap: contacts.gap, contactsW: contacts.width, contactsH: contacts.h, footerH, h: closing.h },
gaps: { client: g.client, table: g.table, tail: g.tail, terms: g.terms, footer: g.footer },
tailH,
closingH: closing.h,
};
}