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
This commit is contained in:
2026-10-06 09:35:20 +05:30
parent cd067ad584
commit 3aa0d33a01
52 changed files with 910 additions and 148 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
import { type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
@@ -150,17 +150,17 @@ export function planBand(model: RenderModel, frame: PageFrame, v: BandVariant, l
// --- table: description | qty | price | total ------------------------------------------------------------
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const cw = {
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "PRICE"),
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "PRICE"),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"];
const align: BandPlan["align"] = ["left", "center", "right", "right"];
const rows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item.rateText, cw.rate - 4, r.tableBody);
const rate = rateCell(item, cw.rate - 4, 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];
+15 -2
View File
@@ -1,6 +1,6 @@
import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { roleToken } from "../../fonts/roles";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -82,6 +82,9 @@ export interface ClassicPlan {
closingH: number;
}
const QTY_MIN = 40;
const QTY_MAX = 84;
const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leading as number;
/**
@@ -92,12 +95,22 @@ const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leadi
export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan {
const gaps = elasticGaps(frame);
const hsn = Slot.HsnSac(model);
// The quantity column grows to hold "12.75 km" on one line, but the description keeps its 160 pt: a text wider than
// that room wraps at its spaces.
const fixedBesideQty = 28 + (hsn ? 62 : 0) + 92 + 88 + TABLE_GAP * (hsn ? 5 : 4);
const qtyRoom = Math.max(QTY_MIN, Math.min(QTY_MAX, Math.floor(frame.contentWidth - fixedBesideQty - 160)));
const qtyNeed = Math.ceil(
Math.max(
measureToken(getMeasurer(), "tableHead", "Qty"),
...wrapAwareTexts(model.items.map((i) => i.qtyText), "tableBody", qtyRoom).map((t) => measureToken(getMeasurer(), "tableBody", t)),
),
);
const columns: ColSpec[] = [
{ fixed: 28 },
{ fill: true, min: 160 },
...(hsn ? [{ fixed: 62 } as ColSpec] : []),
{ fixed: 92 },
{ fixed: 40 },
{ fixed: Math.min(qtyRoom, Math.max(QTY_MIN, qtyNeed + 2)) },
{ fixed: 88 },
];
const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"];
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
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";
@@ -19,7 +19,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
const MIN_ADDRESS_W = 120;
/** Space between a mark and the tagline under a wordmark logo. */
const WORDMARK_TAG_GAP = 3;
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
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} :`);
@@ -65,7 +65,7 @@ export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number):
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
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];
@@ -245,10 +245,10 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
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.rateText));
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, model.items.map((i) => i.qtyText)) + 12 + rule);
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));
+13
View File
@@ -297,6 +297,19 @@ export const FIXTURES: FixtureSpec[] = [
{ description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" },
],
},
{
// Units of measure: a decimal distance, an area, a time that is not a whole number of hundredths (4 h 20 min),
// a 12-character custom unit and a month, so the quantity and rate columns see the longest texts they can.
name: "units",
items: [
{ description: "Travel to the client's studio", mode: "rate", rate: 18.5, unit: "km", quantity: 12.75, amount: 0, sortOrder: 0, hsnSac: "996601" },
{ description: "Acoustic treatment, floor area", mode: "rate", rate: 85, unit: "sqft", quantity: 1250.5, amount: 0, sortOrder: 1, hsnSac: "995411" },
{ description: "Evening studio session", mode: "rate", rate: 1500, unit: "hour", quantity: 4 + 1 / 3, amount: 0, sortOrder: 2, hsnSac: "998397" },
{ description: "Packed equipment cases", mode: "rate", rate: 99.5, unit: "wooden crate", quantity: 120, amount: 0, sortOrder: 3, hsnSac: "998397" },
{ description: "Retainer", mode: "rate", rate: 25000, unit: "month", quantity: 1, amount: 0, sortOrder: 4, hsnSac: "998397" },
{ description: "Setup fee", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 5, hsnSac: "997212" },
],
},
{
name: "long-notes",
items: 30,
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { drawnText, getMeasurer, measureToken } from "../../engine/measure";
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";
@@ -37,7 +37,7 @@ export function tableRows(model: RenderModel, v: HighlighterVariant, rateW: numb
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
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];
@@ -158,14 +158,14 @@ export function planHighlighter(model: RenderModel, frame: PageFrame, v: Highlig
// --- table -----------------------------------------------------------------------------------------------------------------
const t = v.table;
const innerW = W - 2 * fr;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
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.rateText, 10000, r.tableBody);
const cell = rateCell(i, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—";
});
const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
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),
};
+5 -5
View File
@@ -1,6 +1,6 @@
import { colsDetailed, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
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";
@@ -140,13 +140,13 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
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 = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
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.rateText)), "Unit Price", idx(1)),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)),
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 }];
@@ -154,7 +154,7 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
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.rateText, rateInner, r.tableBody);
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];
});
+1 -1
View File
@@ -282,7 +282,7 @@ describe("table", () => {
expect(r0.h).toBeCloseTo(32, 0);
expect(r1.y - r0.y).toBeCloseTo(32, 0);
expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0);
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0);
expect(r0.x).toBeCloseTo(31.5, 0);
});
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -62,7 +62,7 @@ export function tableRows(model: RenderModel, v: MarbleVariant, rateW: number):
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, r.tableBody);
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];
@@ -165,14 +165,14 @@ export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVarian
const t = v.table;
const need = (texts: string[], head: string, pad: number, headStep: number) =>
Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6;
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
const rateOnly = model.items.map((i) => (i.priceText ?? "—"));
// The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour".
const shortRate = model.items.map((i) => {
const cell = rateCell(i.rateText, 10000, r.tableBody);
const cell = rateCell(i, 10000, r.tableBody);
return typeof cell === "string" ? cell : "—";
});
const cw = {
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)),
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0, 0)),
rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))),
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)),
};
+1 -1
View File
@@ -193,7 +193,7 @@ describe("table, totals and footer", () => {
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
}
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { fitSize } from "../../blocks/FitText";
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
@@ -18,7 +18,7 @@ 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) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
@@ -58,7 +58,7 @@ export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number
const r = v.roles;
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, priceW, r.tableBody);
const rate = rateCell(item, 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];
@@ -165,11 +165,11 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
// --- table ---------------------------------------------------------------------------------------------------------
const t = v.table;
const priced = model.items.map((i) => priceOf(i.rateText));
const priced = model.items.map((i) => priceOf(i));
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)),
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "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 }];
+6 -3
View File
@@ -131,9 +131,12 @@ describe("helpers", () => {
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
const body = linea.roles.tableBody;
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr");
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(null, 90, body)).toBe("—");
const rate = (price: string, per: string, short: string) => ({ priceText: price, perText: per, rateShort: `${price}/${short}` });
expect(rateCell(rate("₹1,200.00", "per hour", "hr"), 90, body)).toBe("₹1,200.00/hr");
expect(rateCell(rate("₹1,200.00", "per session", "session"), 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(rate("₹18.50", "per sq ft", "sq ft"), 200, body)).toBe("₹18.50/sq ft");
expect(rateCell(rate("₹18.50", "per bundle", "bundle"), 20, body)).toEqual(["₹18.50", "per bundle"]);
expect(rateCell({ priceText: null, perText: null, rateShort: null }, 90, body)).toBe("—");
expect(getMeasurer()).toBeTruthy();
});
});
+13 -14
View File
@@ -1,11 +1,11 @@
import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import { guardPresence } from "../../blocks/TableGuard";
import type { RenderModel } from "../../model/build";
import type { RenderItem, RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract";
import type { GapRange, NeutralVariant } from "./variant";
@@ -103,18 +103,17 @@ export function metaRows(model: RenderModel): MetaRowSpec[] {
return rows;
}
const SHORT_UNIT: Record<string, string> = { second: "/sec", minute: "/min", hour: "/hr", session: "/session", unit: "/unit" };
/**
* The rate cell: "₹1,200.00/hr" on one line when it fits the column, else the amount with its unit
* ("per hour") on a second line, which keeps one-line rows one line tall.
*/
export function rateCell(rateText: string | null, width: number, role: NeutralVariant["roles"]["tableBody"]): TableCellValue {
if (!rateText) return "—";
const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText);
if (!m) return rateText;
const short = `${m[1]}${SHORT_UNIT[m[2]]}`;
return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`];
export function rateCell(
item: Pick<RenderItem, "priceText" | "perText" | "rateShort">,
width: number,
role: NeutralVariant["roles"]["tableBody"],
): TableCellValue {
if (!item.priceText || !item.perText || !item.rateShort) return "—";
return measureToken(getMeasurer(), role, item.rateShort) <= width ? item.rateShort : [item.priceText, item.perText];
}
export interface NeutralPlan {
@@ -224,12 +223,12 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
const widest = (role: NeutralVariant["roles"]["tableBody"], texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const hsnHead = hsnCol?.header ?? "";
const c = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"),
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit price"),
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty"),
amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"),
};
const columns: ColSpec[] = [
@@ -246,7 +245,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
const tableRows: TableCellValue[][] = model.items.map((item, i) => [
hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines,
...(hsnCol ? [hsnCol.values[i]] : []),
rateCell(item.rateText, rateW, r.tableBody),
rateCell(item, rateW, r.tableBody),
item.qtyText,
item.amountText,
]);
+7 -7
View File
@@ -1,6 +1,6 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { DOME_BOX } from "../../decor/Asterisk";
import { fitSize } from "../../blocks/FitText";
@@ -68,13 +68,13 @@ export function idLines(role: TypeToken, text: string, width: number): number {
/** Plain shrink-wrapped width of a one-line text. */
export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1;
export const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
export const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
/** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */
export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, body);
const rate = rateCell(item, rateW, body);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
@@ -193,12 +193,12 @@ export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVarian
const align: PurplePlan["align"] = ["left", "center", "right", "right"];
const padY = (t.rowH - leading(r.tableBody)) / 2;
const headPadY = (t.headH - leading(r.tableHead)) / 2;
const priced = model.items.map((i) => priceOf(i.rateText));
const priced = model.items.map((i) => priceOf(i));
const build = (left: number, base: { qty: number; rate: number; amount: number }) => {
const tableW = round2(W - left - t.right);
const inner = tableW - 2 * t.inset;
const cw = {
qty: numericColW(base.qty, r.tableBody, r.tableHead, model.items.map((i) => i.qtyText), "QTY"),
qty: numericColW(base.qty, r.tableBody, r.tableHead, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"),
amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"),
};
@@ -424,10 +424,10 @@ export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVarian
// --- table ----------------------------------------------------------------------------------------------------
const t = v.table;
const tableW = round2(W - t.left);
const priced = model.items.map((i) => priceOf(i.rateText));
const priced = model.items.map((i) => priceOf(i));
const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4;
const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4;
const qtyNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6;
const qtyNeed = Math.ceil(Math.max(0, ...wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody).map((t) => measureToken(m, r.tableBody, t)), measureToken(m, r.headNum, "QTY"))) + 6;
const amount = Math.max(t.cols.amount, amountNeed);
const rate = Math.max(t.cols.rate, rateNeed);
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));