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 a014a907e4
40 changed files with 2185 additions and 832 deletions
+221
View File
@@ -0,0 +1,221 @@
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { AuditIssue } from "../engine/audit";
import { PAGE_SIZES } from "../engine/geometry";
import { PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, CLOSING_ID, TOTALS_ID } from "../engine/ids";
import { walk, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree";
import { forbiddenProbe, SLOT_RULES, slotContent, type SlotName } from "../model/slots";
import { frameForPrefs, renderInvoicePdf } from "../render/invoice";
import { loadCanvas, pagePng } from "../testing/rasterize";
import { SLOT_ID } from "../blocks/slots";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "./classic/ids";
import { HEADER_GAP, headerCols } from "./classic/plan";
import { FIXTURE_GSTIN, FIXTURES, buildFixture, ensureSetup, makeItems, PAGE_SIZE_IDS, prefsFor, renderFixture, type FixtureSpec, type Rendered } from "./harness";
import { TEMPLATES } from "./registry";
/**
* Template harness: every registered template x {A4, Letter} x the fixture matrix, plus an item-count
* sweep. A failure here means the template or a primitive is wrong, not the assertion.
*/
const OUT_DIR = process.env.PDF_OUT_DIR ?? "";
const SIZE_PT = { A4: [PAGE_SIZES.A4.width, PAGE_SIZES.A4.height], LETTER: [612, 792] } as const;
const squash = (s: string) => s.replace(/\s+/g, "").toLowerCase();
const textOf = (r: Rendered) => r.pdf.pages.flatMap((p) => p.items.map((t) => t.str)).join(" ");
const nodesOf = (page: LayoutPage) => {
const out: LayoutNode[] = [];
walk(page.nodes, (n) => out.push(n));
return out;
};
const withId = (page: LayoutPage, id: string) => nodesOf(page).filter((n) => n.id === id);
const withIdPrefix = (page: LayoutPage, prefix: string) => nodesOf(page).filter((n) => n.id?.startsWith(prefix));
const hits = (a: Rect, b: Rect, tol = 0.5) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > tol && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > tol;
function textLinesUnder(root: LayoutNode): Rect[] {
const out: Rect[] = [];
const visit = (n: LayoutNode) => {
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ x: l.x, y: l.y, w: Math.max(0, l.w - (n.letterSpacing ?? 0)), h: l.h });
n.children.forEach(visit);
};
visit(root);
return out;
}
function checkRendered(r: Rendered, label: string) {
const { model } = r.fixture;
const layout = r.result.core.layout;
const n = r.pdf.pages.length;
const ctx = (msg: string) => `${label}: ${msg}`;
// Page geometry as pdf.js reads it from the PDF.
const [w, h] = SIZE_PT[r.prefs.pageSize];
r.pdf.pages.forEach((p, i) => {
expect(Math.abs(p.width - w), ctx(`page ${i + 1} width ${p.width}`)).toBeLessThanOrEqual(0.5);
expect(Math.abs(p.height - h), ctx(`page ${i + 1} height ${p.height}`)).toBeLessThanOrEqual(0.5);
});
expect(r.result.pages).toBe(n);
// The audit: overlap, overflow-x, safe zone, truncation, empty render text, header-only page,
// split blocks, closing block position.
const errors = r.result.issues.filter((i: AuditIssue) => i.severity === "error");
expect(errors.map((i) => `${i.code} p${i.page}: ${i.message}`), ctx("audit errors")).toEqual([]);
// The reported bug: supplier and address boxes must stay clear of the title box.
const supplier = withId(layout[0], SUPPLIER_ID)[0];
const title = withId(layout[0], TITLE_ID)[0];
const meta = withId(layout[0], META_ID)[0];
expect(supplier && title && meta, ctx("supplier, title and meta nodes exist")).toBeTruthy();
expect(hits(supplier.box, title.box), ctx(`supplier box ${JSON.stringify(supplier.box)} meets title box ${JSON.stringify(title.box)}`)).toBe(false);
expect(hits(supplier.box, meta.box), ctx("supplier box meets meta box")).toBe(false);
const titleInk = textLinesUnder(title);
for (const line of textLinesUnder(supplier)) for (const t of titleInk) expect(hits(line, t), ctx("a supplier line meets the title text")).toBe(false);
// Header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too):
// supplier text stays inside its column and every meta line starts at or right of the meta column.
const frame = frameForPrefs(r.prefs);
const [supplierW] = headerCols(frame.contentWidth);
const supplierRight = frame.pad.left + supplierW;
const metaLeft = supplierRight + HEADER_GAP;
for (const line of textLinesUnder(supplier)) expect(line.x + line.w, ctx(`supplier line [${line.x}..${line.x + line.w}] past its column edge ${supplierRight}`)).toBeLessThanOrEqual(supplierRight + 0.5);
for (const line of textLinesUnder(meta)) expect(line.x, ctx(`meta line at ${line.x} left of the meta column ${metaLeft}`)).toBeGreaterThanOrEqual(metaLeft - 0.5);
// Header repeats on every continuation page that has rows; no page shows a header without rows.
layout.forEach((page, i) => {
const rows = withIdPrefix(page, TABLE_ROW_PREFIX).length;
const heads = withId(page, TABLE_HEAD_ID).length;
if (i > 0 && rows > 0) expect(heads, ctx(`page ${i + 1} repeats the table header`)).toBe(1);
if (heads > 0) expect(rows, ctx(`page ${i + 1} has a header but no rows`)).toBeGreaterThan(0);
});
// Totals and signatory are never split and never duplicated.
for (const id of [TOTALS_ID, CLOSING_ID, SLOT_ID.signatory]) {
const pages = layout.flatMap((p, i) => (withId(p, id).length > 0 ? [i + 1] : []));
expect(pages, ctx(`#${id} appears on exactly one page`)).toHaveLength(1);
}
expect(withId(layout[n - 1], CLOSING_ID), ctx("closing block is on the last page")).toHaveLength(1);
// "Page k of N" from the layout tree and from the extracted text.
layout.forEach((page, i) => {
const want = `Page ${i + 1} of ${n}`;
expect(withId(page, PAGE_LABEL_ID).flatMap((nd) => (nd.lines ?? []).map((l) => l.text)), ctx(`page ${i + 1} label (layout)`)).toEqual([want]);
const labels = r.pdf.pages[i].items.map((t) => t.str.trim()).filter((s) => /^Page \d+ of \d+$/.test(s));
expect(labels, ctx(`page ${i + 1} label (text)`)).toEqual([want]);
});
// Extracted text.
const text = textOf(r);
expect(r.pdf.raw, ctx("no Helvetica")).not.toContain("/BaseFont /Helvetica");
expect(text, ctx("rupee extracts as U+20B9, never a superscript one")).not.toContain("¹");
expect(text).toContain("₹");
for (const t of r.pdf.pages.flatMap((p) => p.items)) {
if (/\d\.\d\d$/.test(t.str.trim())) expect(t.str, ctx(`amount "${t.str}" carries the rupee sign`)).toContain("₹");
}
const flat = squash(text);
expect(flat, ctx("invoice number")).toContain(squash(model.meta.number));
expect(flat, ctx("total")).toContain(squash(model.totals.totalText));
expect(text.match(/Generated with Voiced/g)?.length, ctx("footer on every page")).toBe(n);
if (model.docType === "tax_invoice") expect(flat, ctx("supplier GSTIN")).toContain(squash(FIXTURE_GSTIN.length ? (model.supplier.gstin ?? "") : ""));
// Contract slots.
const rules = SLOT_RULES[model.docType];
for (const slot of rules.required) {
const content = slotContent(model, slot);
if (model.docType === "tax_invoice") expect(content.length, ctx(`fixture has content for required slot ${slot}`)).toBeGreaterThan(0);
for (const s of content) expect(flat, ctx(`slot ${slot} prints "${s}"`)).toContain(squash(s));
}
const ids = new Set(layout.flatMap((p) => nodesOf(p).flatMap((nd) => (nd.id ? [nd.id] : []))));
const idFor: Partial<Record<SlotName, string>> = {
gstin: `${SLOT_ID.gstin}-supplier`,
pos: SLOT_ID.pos,
taxRows: SLOT_ID.taxRows,
amountInWords: SLOT_ID.amountInWords,
signatory: SLOT_ID.signatory,
reverseCharge: SLOT_ID.reverseCharge,
};
for (const slot of rules.required) {
const id = idFor[slot];
if (id) expect(ids.has(id), ctx(`slot node #${id}`)).toBe(true);
}
for (const slot of rules.forbidden) {
const id = idFor[slot];
if (id) expect(ids.has(id), ctx(`forbidden slot node #${id}`)).toBe(false);
const probes = forbiddenProbe(model, slot, r.fixture.spec.registered === false ? "27ZZZZZ9999Z1Z9" : undefined);
for (const p of probes) expect(flat, ctx(`forbidden slot ${slot} leaked "${p}"`)).not.toContain(squash(p));
}
}
describe("template harness: fixtures", () => {
for (const template of Object.values(TEMPLATES)) {
describe(template.id, () => {
for (const size of PAGE_SIZE_IDS) {
for (const spec of FIXTURES) {
it(`${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: template.id });
const r = await renderFixture(spec, prefs);
checkRendered(r, `${template.id}/${spec.name}/${size}`);
});
}
}
});
}
});
describe("template harness: determinism", () => {
for (const template of Object.values(TEMPLATES)) {
for (const size of PAGE_SIZE_IDS) {
for (const name of ["cgst-sgst", "rows-60", "cancelled", "unregistered"]) {
it(`${template.id}/${name}/${size}: same input gives identical bytes and fingerprint`, async () => {
ensureSetup();
const spec = FIXTURES.find((f) => f.name === name) as FixtureSpec;
const { model } = buildFixture(spec);
const prefs = prefsFor(size, { templateId: template.id });
const a = await renderInvoicePdf({ model, prefs });
const b = await renderInvoicePdf({ model, prefs });
expect(Buffer.from(a.bytes).equals(Buffer.from(b.bytes))).toBe(true);
expect(b.fingerprint).toBe(a.fingerprint);
});
}
}
}
});
describe("template harness: item-count sweep", () => {
for (const template of Object.values(TEMPLATES)) {
for (const size of PAGE_SIZE_IDS) {
it(`${template.id}: 1..45 items on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: template.id });
for (let count = 1; count <= 45; count++) {
const spec: FixtureSpec = { name: `sweep-${count}`, items: makeItems(count, true), invoice: { notes: count % 3 === 0 ? "Payment due within 30 days.\nThank you." : "" }, signature: count % 2 === 0 };
const r = await renderFixture(spec, prefs);
checkRendered(r, `${template.id}/items=${count}/${size} (${r.result.pages} pages)`);
}
});
}
}
});
describe.skipIf(!OUT_DIR)("template harness: samples for visual review", () => {
const samples: Array<{ file: string; spec: FixtureSpec; size: "A4" | "LETTER"; pages: number }> = [
{ file: "classic-A4-tax", spec: { name: "s1", items: 3, signature: true, invoice: { poNumber: "PO-2026-0042", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, size: "A4", pages: 1 },
{ file: "classic-Letter-tax", spec: { name: "s2", items: 3, signature: true, invoice: { poNumber: "PO-2026-0042", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, size: "LETTER", pages: 1 },
{ file: "classic-A4-unregistered", spec: { name: "s3", registered: false, items: 3, invoice: { notes: "Thank you for your business." } }, size: "A4", pages: 1 },
{ file: "classic-A4-60rows", spec: { name: "s4", items: 60, signature: true }, size: "A4", pages: 2 },
{ file: "classic-Letter-60rows", spec: { name: "s5", items: 60, signature: true }, size: "LETTER", pages: 2 },
{ file: "classic-A4-cancelled", spec: { name: "s6", items: 3, invoice: { status: "cancelled" }, signature: true }, size: "A4", pages: 1 },
];
for (const s of samples) {
it(s.file, async () => {
mkdirSync(OUT_DIR, { recursive: true });
const r = await renderFixture(s.spec, prefsFor(s.size));
writeFileSync(path.join(OUT_DIR, `${s.file}.pdf`), r.result.bytes);
const canvas = await loadCanvas();
if (!canvas) return;
for (let p = 1; p <= Math.min(s.pages, r.result.pages); p++) {
writeFileSync(path.join(OUT_DIR, `${s.file}-p${p}.png`), await pagePng(canvas, r.result.bytes, p, 1.5));
}
});
}
});