Add Ledger and Band template families with decor primitives
Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from the reference PDFs with grid and band tables that survive page breaks, full-bleed fixed bands, pennant tabs, gradient corner swooshes with keep-outs, Carbon icon circles, and the user's logo in the measured slot (knockout or white chip on the dark Slate band). Adds static Poppins 500-700 and Inter fonts. Thumbnails regenerated for all eight templates; the item-count sweeps run in their own test file.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import { colsDetailed, type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } 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 = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
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)),
|
||||
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.rateText, 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 };
|
||||
Reference in New Issue
Block a user