Add Ledger and Band template families with decor primitives

Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from
the Canva 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:
2026-10-04 08:34:18 +05:30
parent 3f7c12b7dd
commit c95514ee8d
62 changed files with 4078 additions and 217 deletions
+2 -159
View File
@@ -19,151 +19,9 @@ import { TEMPLATES } from "./registry";
* sweep. A failure here means the template or a primitive is wrong, not the assertion.
*/
import { checkRendered } from "./harness.check";
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);
// Classic's 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. The
// Neutral templates have no shared header row; the box checks above and the audit cover them.
const frame = frameForPrefs(r.prefs);
if (r.prefs.templateId === "classic") {
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);
} else {
// Supplier lines stay inside the text column; the meta block never reaches left of the client column's end.
const margin = { left: frame.pad.left, right: frame.page.width - frame.pad.right };
for (const line of textLinesUnder(supplier)) {
expect(line.x, ctx("supplier line inside the page margins")).toBeGreaterThanOrEqual(margin.left - 0.5);
expect(line.x + line.w, ctx("supplier line inside the page margins")).toBeLessThanOrEqual(margin.right + 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("₹");
// A face without the rupee sign (Open Sans) draws it from the fallback font, which extraction reports as
// its own item right before the digits, so the sign counts when it ends the previous item.
for (const page of r.pdf.pages) {
page.items.forEach((t, i) => {
if (/\d\.\d\d$/.test(t.str.trim())) {
const carried = t.str.includes("₹") || (i > 0 && page.items[i - 1].str.trim().endsWith("₹"));
expect(carried, ctx(`amount "${t.str}" carries the rupee sign`)).toBe(true);
}
});
}
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, () => {
@@ -199,21 +57,6 @@ describe("template harness: determinism", () => {
}
});
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 },