Rebuild the Classic template on the engine with A4/Letter and a template harness

Classic now renders through buildRenderModel, computeFrame, the template
and renderCore, with S.No and HSN/SAC columns, GST slots only through
checked Slot components, taxable value, reverse-charge line, cancelled tag,
continuation band and Page n of N. Letter absorbs its height deficit in
elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item
sweep and asserts zero audit errors, repeated headers, no split blocks,
rupee extraction and deterministic output. The legacy InvoiceDocument is
removed.
This commit is contained in:
2026-10-04 05:31:14 +05:30
parent 8d4f8e666b
commit 3a99b7284b
40 changed files with 2185 additions and 832 deletions
+39 -25
View File
@@ -10,6 +10,11 @@ import { pdfFlags } from "./pdfProps";
import { Cell, Row } from "./Row";
import { Txt } from "./Txt";
/** A cell: one string, or several (one Txt each, stacked), for multi-line values. */
export type TableCellValue = string | string[];
const cellParts = (v: TableCellValue): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
export interface TableRoles {
head: RoleName | TypeToken;
body: RoleName | TypeToken;
@@ -22,6 +27,8 @@ export interface TableLook {
padY?: number;
/** Bottom border of each row in pt (counted in the row height). Default 0. */
border?: number;
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
padRight?: number[];
faceOverrides?: Readonly<Record<string, string>>;
}
@@ -30,8 +37,8 @@ export interface ItemsTablePlanInput extends TableLook {
gap: number;
/** Table width in pt (the content width of the page, or of the cell holding the table). */
width: number;
header: string[];
rows: string[][];
header: TableCellValue[];
rows: TableCellValue[][];
measurer: Measurer;
roles: TableRoles;
/** Height of whatever follows the last row and must stay with it (the totals). */
@@ -49,20 +56,23 @@ export interface ItemsTablePlan {
rowLines: number[];
}
function cellHeight(m: Measurer, role: RoleName | TypeToken, text: string, inner: number, look: TableLook): { lines: number; height: number } {
function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } {
const t = roleToken(role);
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
const lines = Math.max(
1,
m.wrapLines(drawnText(t, text), inner, {
face: resolveFace(t.face, look.faceOverrides),
weight: t.weight,
italic: t.italic ?? false,
size: t.size,
letterSpacing: (t.tracking ?? 0) * t.size,
}).length,
);
let lines = 0;
for (const text of cellParts(value)) {
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
lines += Math.max(
1,
m.wrapLines(drawnText(t, text), inner, {
face: resolveFace(t.face, look.faceOverrides),
weight: t.weight,
italic: t.italic ?? false,
size: t.size,
letterSpacing: (t.tracking ?? 0) * t.size,
}).length,
);
}
return { lines, height: lines * t.leading };
}
@@ -84,8 +94,8 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan");
const { widths } = colsDetailed(width, columns, gap);
const inner = widths.map((w) => Math.max(0, w - 2 * padX));
const heightOf = (role: RoleName | TypeToken, cells: string[]) => {
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => {
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input));
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border };
};
@@ -110,8 +120,8 @@ export interface TableRowsProps extends TableLook {
columns: ColSpec[];
gap: number;
width: number;
header: string[];
rows: string[][];
header: TableCellValue[];
rows: TableCellValue[][];
roles: TableRoles;
/** Per column; default left. */
align?: Array<"left" | "right" | "center">;
@@ -122,6 +132,8 @@ export interface TableRowsProps extends TableLook {
headerStyle?: Record<string, unknown>;
rowStyle?: Record<string, unknown>;
borderColor?: string;
/** Text colour of the header cells. */
headColor?: string;
}
/**
@@ -129,7 +141,7 @@ export interface TableRowsProps extends TableLook {
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
* the repeated header is positioned against the container's top.
*/
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, ...look }: TableRowsProps) {
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, ...look }: TableRowsProps) {
const padX = look.padX ?? 0;
const padY = look.padY ?? 4;
const border = look.border ?? 0;
@@ -138,12 +150,14 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
...extra,
});
const cells = (role: RoleName | TypeToken, values: string[]) =>
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) =>
values.map((v, i) => (
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX }}>
<Txt role={role} width={Math.max(0, widths[i] - 2 * padX)} align={align[i]} faceOverrides={look.faceOverrides}>
{v}
</Txt>
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
{cellParts(v).map((part, li) => (
<Txt key={li} role={role} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
{part}
</Txt>
))}
</Cell>
));
@@ -151,7 +165,7 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
<AvailableWidthProvider width={width}>
<View>
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
{cells(roles.head, header)}
{cells(roles.head, header, headColor)}
</Row>
{rows.map((r, i) => (
<Row