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
+335
View File
@@ -0,0 +1,335 @@
import { Image, View } from "@react-pdf/renderer";
import { FILL, pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { LOGO_GAP } from "../../engine/logo";
import { getMeasurer, measureToken } from "../../engine/measure";
import { roleToken, TYPE_ROLES } from "../../fonts/roles";
import { AvailableWidthProvider, useAvailableWidth } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FitText } from "../../blocks/FitText";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Tracked } from "../../blocks/Tracked";
import { Txt, type TxtChild, type TxtProps } from "../../blocks/Txt";
import type { RenderModel } from "../../model/build";
import type { PlacedLogo, TemplateProps } from "../contract";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "./ids";
import {
CANCELLED_INK,
CANCELLED_SENTENCE,
CLOSING_GAP_COLS,
GRAND_RULE,
headerCols,
HEADER_GAP,
INK,
INK_SUB,
META_LABEL_W,
MUTED,
planClassic,
RULE,
SIGNATURE_W,
TABLE_BORDER,
TABLE_GAP,
TABLE_HEAD_TOP_RULE,
TABLE_PAD_Y,
TOTALS_AMOUNT_W,
TOTALS_ROW_PAD,
TOTALS_W,
type Gaps,
} from "./plan";
/** Text as wide as its cell, so alignment and wrapping use the real box. */
function FullTxt({ children, ...rest }: Omit<TxtProps, "width" | "children" | "grow" | "maxLines"> & { children?: TxtChild }) {
const w = useAvailableWidth();
return (
<Txt {...rest} width={w}>
{children}
</Txt>
);
}
function SupplierBlock({ model, logo }: { model: RenderModel; logo: PlacedLogo | null }) {
const w = useAvailableWidth();
const s = model.supplier;
const beside = logo !== null && !logo.stacked;
const textW = beside ? w - logo.w - LOGO_GAP : w;
const text = (
<AvailableWidthProvider width={textW}>
<SafeTxt role="h1" style={{ marginBottom: 3 }}>
{s.name}
</SafeTxt>
{s.addressLines.map((line, i) => (
<SafeTxt key={i} role="body" color={INK_SUB}>
{line}
</SafeTxt>
))}
{s.email ? (
<IdText role="body" color={INK_SUB}>
{s.email}
</IdText>
) : null}
{s.phone ? (
<IdText role="body" color={INK_SUB}>
{s.phone}
</IdText>
) : null}
{s.pan ? (
<IdText role="body" color={INK_SUB}>{`PAN: ${s.pan}`}</IdText>
) : null}
<Slot.Gstin model={model} party="supplier" color={INK_SUB} />
</AvailableWidthProvider>
);
const image = logo ? (
<Image
src={logo.dataUri}
style={{
...pinned(logo.w),
height: logo.h,
objectFit: "contain",
...(beside ? { marginRight: LOGO_GAP } : { objectPositionX: 0, marginBottom: 8 }),
}}
/>
) : null;
if (beside) {
return (
<View id={SUPPLIER_ID} style={{ flexDirection: "row", ...pinned(w) }}>
{image}
<View style={{ ...FILL }}>{text}</View>
</View>
);
}
return (
<View id={SUPPLIER_ID} style={{ flexDirection: "column", ...pinned(w) }}>
{image}
<View style={pinned(w)}>{text}</View>
</View>
);
}
function CancelledTag() {
const label = TYPE_ROLES.label;
const w = Math.ceil(measureToken(getMeasurer(), label, "Cancelled")) + 2;
return (
<View
style={{
alignSelf: "flex-end",
borderWidth: 0.8,
borderColor: CANCELLED_INK,
paddingVertical: 2.5,
paddingHorizontal: 6,
marginBottom: 8,
}}
>
<Txt role="label" color={CANCELLED_INK} width={w}>
Cancelled
</Txt>
</View>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<Row widths={[{ fixed: META_LABEL_W }, { fill: true }]}>
<FullTxt role="body" color={MUTED}>
{label}
</FullTxt>
<MetaValue>{value}</MetaValue>
</Row>
);
}
function MetaValue({ children }: { children: string }) {
const w = useAvailableWidth();
return (
<FitText role="bodyStrong" width={w} align="right">
{children}
</FitText>
);
}
function MetaBlock({ model }: { model: RenderModel }) {
const w = useAvailableWidth();
const title = roleToken("title");
// The box is one tracking step wider than the column and the right-aligned text ends one step
// before its edge, so the last glyph lines up with the values below.
const step = (title.tracking ?? 0) * title.size;
return (
<View id={META_ID} style={pinned(w)}>
<Tracked id={TITLE_ID} role="title" align="right" width={w + step} style={{ marginBottom: 8 }}>
{model.docTitle}
</Tracked>
{model.cancelled ? <CancelledTag /> : null}
<MetaRow label={model.meta.numberLabel} value={model.meta.number} />
<MetaRow label="Date" value={model.meta.date} />
{model.meta.dueDate ? <MetaRow label="Due Date" value={model.meta.dueDate} /> : null}
</View>
);
}
function PartyBlock({ model, gaps }: { model: RenderModel; gaps: Gaps }) {
const c = model.client;
return (
<Row widths={[{ frac: 0.5 }, { frac: 0.5 }]} gap={HEADER_GAP} style={{ marginBottom: gaps.table }} wrap={false}>
<View>
<Txt role="label" color={MUTED} style={{ marginBottom: 3 }}>
Billed To
</Txt>
<SafeTxt role="bodyStrong">{c.name}</SafeTxt>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role="body">
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" />
<Slot.PlaceOfSupply model={model} />
</View>
<View>
{c.poNumber ? (
<>
<Txt role="label" color={MUTED} style={{ marginBottom: 3 }}>
PO Number
</Txt>
<IdText role="bodyStrong">{c.poNumber}</IdText>
</>
) : null}
</View>
</Row>
);
}
function TotalsRow({ label, amount }: { label: string; amount: string }) {
return (
<Row widths={[{ fill: true }, { fixed: TOTALS_AMOUNT_W }]} style={{ paddingVertical: TOTALS_ROW_PAD }}>
<FullTxt role="body">{label}</FullTxt>
<FullTxt role="body" align="right">
{amount}
</FullTxt>
</Row>
);
}
function TotalsBlock({ model, gaps }: { model: RenderModel; gaps: Gaps }) {
const t = model.totals;
return (
<View id={TOTALS_ID} wrap={false} style={{ marginTop: gaps.totals }}>
<Row widths={[{ fill: true }, { fixed: TOTALS_W }]}>
<View />
<View>
<TotalsRow label="Subtotal" amount={t.subtotalText} />
{t.discountText ? <TotalsRow label="Discount" amount={`- ${t.discountText}`} /> : null}
{t.taxableText ? <TotalsRow label="Taxable value" amount={t.taxableText} /> : null}
<Slot.TaxRows model={model} amountWidth={TOTALS_AMOUNT_W} rowStyle={{ paddingVertical: TOTALS_ROW_PAD }} />
<Row
widths={[{ fill: true }, { fixed: TOTALS_AMOUNT_W }]}
style={{ borderTopWidth: GRAND_RULE, borderTopColor: INK, marginTop: 4, paddingTop: 6 }}
>
<FullTxt role="h2">Total</FullTxt>
<FullTxt role="h2" align="right">
{t.totalText}
</FullTxt>
</Row>
</View>
</Row>
<Slot.AmountInWords model={model} style={{ marginTop: gaps.words }} />
<Slot.ReverseCharge model={model} role="small" color={INK_SUB} style={{ marginTop: 4 }} />
</View>
);
}
function BankBlock({ model }: { model: RenderModel }) {
const bank = model.bank;
if (!bank) return <View />;
return (
<View>
<Txt role="label" color={MUTED} style={{ marginBottom: 3 }}>
Pay To
</Txt>
<SafeTxt role="bodyStrong">{bank.accountName}</SafeTxt>
{bank.lines.map((l, i) => (
<SafeTxt key={i} role="body">
{l.label === "Bank" ? l.value : `${l.label}: ${l.value}`}
</SafeTxt>
))}
</View>
);
}
export function ClassicLayout({ model, frame, logo }: TemplateProps) {
const plan = planClassic(model, frame);
const { gaps } = plan;
const [, metaW] = headerCols(frame.contentWidth);
return (
<>
<View wrap={false}>
<Row widths={[{ fill: true }, { fixed: metaW }]} gap={HEADER_GAP}>
<SupplierBlock model={model} logo={logo} />
<MetaBlock model={model} />
</Row>
</View>
<View style={{ borderBottomWidth: 1, borderBottomColor: RULE, marginTop: gaps.dividerTop, marginBottom: gaps.dividerBottom }} />
<PartyBlock model={model} gaps={gaps} />
{/* A TableGuard prop, not a node flag (it spreads pdfFlags itself); the spread keeps the flag lint quiet. */}
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<TableRows
columns={plan.columns}
gap={TABLE_GAP}
width={frame.contentWidth}
header={plan.header}
rows={plan.rows}
roles={{ head: "tableHead", body: "tableBody" }}
align={plan.align}
padY={TABLE_PAD_Y}
border={TABLE_BORDER}
padRight={plan.padRight}
borderColor={RULE}
headColor={MUTED}
headerStyle={{ borderTopWidth: TABLE_HEAD_TOP_RULE, borderTopColor: INK, borderBottomColor: INK }}
tailH={plan.tailH}
tail={<TotalsBlock model={model} gaps={gaps} />}
/>
{model.notesLines.length > 0 ? (
<View style={{ marginTop: gaps.notes }}>
{/*
The label travels with the first line, and no paragraph splits: a Text cut between pages
leaves a zero-line node on the first page (audit: text-truncated). The block as a whole may
still run over several pages when the notes are long.
*/}
<View wrap={false}>
<Txt role="label" color={MUTED} style={{ marginBottom: 3 }}>
Notes
</Txt>
<SafeTxt role="body">{model.notesLines[0]}</SafeTxt>
</View>
{model.notesLines.slice(1).map((line, i) => (
<SafeTxt key={i} role="body" wrap={false}>
{line}
</SafeTxt>
))}
</View>
) : null}
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: gaps.closing }}>
<Row widths={[{ fill: true }, { fixed: SIGNATURE_W }]} gap={CLOSING_GAP_COLS}>
<BankBlock model={model} />
<Slot.Signatory model={model} />
</Row>
{model.cancelled ? (
<Txt role="small" color={CANCELLED_INK} width={frame.contentWidth} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</View>
</>
);
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { PAGE_SIZES } from "../../engine/geometry";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { walk } from "../../engine/layoutTree";
import { frameForPrefs } from "../../render/invoice";
import { marginsFor } from "../contract";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture } from "../harness";
import { TEMPLATES, getTemplate } from "../registry";
import { classic } from "./index";
import { elasticGaps, headerCols, planClassic } from "./plan";
ensureSetup();
describe("classic geometry", () => {
it("splits the A4 header 299.28 / 24 / 192 and gives the extra Letter width to the supplier", () => {
const a4 = frameForPrefs(prefsFor("A4"));
expect(a4.contentWidth).toBeCloseTo(515.28, 2);
expect(headerCols(a4.contentWidth)).toEqual([299.28, 192]);
const letter = frameForPrefs(prefsFor("LETTER"));
expect(letter.contentWidth).toBe(532);
expect(headerCols(letter.contentWidth)).toEqual([316, 192]);
});
it("template margins keep the content bottom 48 pt above the page edge, as Phase 0 did", () => {
expect(marginsFor(classic, "template")).toEqual({ top: 40, right: 40, bottom: 24, left: 40 });
const frame = frameForPrefs(prefsFor("A4"));
expect(frame.pad).toEqual({ top: 40, right: 40, bottom: 48, left: 40 });
expect(frame.page).toBe(PAGE_SIZES.A4);
});
it("narrow and normal presets apply on all sides, never below the template floor", () => {
expect(marginsFor(classic, "narrow")).toEqual({ top: 36, right: 36, bottom: 36, left: 36 });
expect(marginsFor(classic, "normal")).toEqual({ top: 54, right: 54, bottom: 54, left: 54 });
});
it("the continuation band fits inside the narrowest top margin", () => {
const f = frameForPrefs(prefsFor("A4", { margins: "narrow" }));
expect(f.pad.top - f.continuationReserve).toBeGreaterThanOrEqual(18);
});
});
describe("elastic gaps", () => {
const nominal = elasticGaps({ heightDeficit: 0 });
const letter = elasticGaps({ heightDeficit: 49.89 });
const floor = elasticGaps({ heightDeficit: 1000 });
it("are nominal on A4", () => {
expect(nominal).toEqual({ dividerTop: 16, dividerBottom: 16, table: 22, totals: 12, words: 12, notes: 14, closing: 34 });
});
it("give back the Letter height deficit from their slack, never below the documented minimum", () => {
const sum = (g: typeof nominal) => Object.values(g).reduce((a, b) => a + b, 0);
expect(sum(nominal) - sum(letter)).toBeCloseTo(49.89, 1);
for (const k of Object.keys(nominal) as Array<keyof typeof nominal>) {
expect(letter[k]).toBeLessThan(nominal[k]);
expect(letter[k]).toBeGreaterThanOrEqual(floor[k]);
}
expect(floor).toEqual({ dividerTop: 8, dividerBottom: 8, table: 12, totals: 6, words: 6, notes: 8, closing: 18 });
expect(sum(nominal) - sum(floor)).toBe(60);
});
});
describe("classic plan matches the laid-out blocks", () => {
// The totals tail and the closing block carry minPresenceAhead values predicted from the measurer;
// too low and a block can start a page without its spacer. Predictions must be at or just above the truth.
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size);
const { model } = buildFixture(spec);
const frame = frameForPrefs(prefs);
const plan = planClassic(model, frame);
const r = await renderFixture(spec, prefs);
const find = (id: string) => {
const hits: Array<{ h: number }> = [];
for (const page of r.result.core.layout) walk(page.nodes, (n) => n.id === id && hits.push({ h: n.box.h }));
expect(hits).toHaveLength(1);
return hits[0].h;
};
const tailGap = plan.tailH - find(TOTALS_ID) - plan.gaps.totals;
expect(tailGap, "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(tailGap).toBeLessThanOrEqual(3);
const closingGap = plan.closingH - find(CLOSING_ID);
expect(closingGap, "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(closingGap).toBeLessThanOrEqual(4);
});
}
}
});
describe("registry", () => {
it("has Classic and falls back to it for an unknown or missing id", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic"]);
expect(getTemplate("classic")).toBe(classic);
expect(getTemplate("no-such-template")).toBe(classic);
expect(getTemplate(undefined)).toBe(classic);
});
});
+4
View File
@@ -0,0 +1,4 @@
/** Layout-tree ids the Classic layout stamps for the template harness (shared ones live in engine/ids.ts). */
export const SUPPLIER_ID = "supplier";
export const META_ID = "meta";
export const TITLE_ID = "doc-title";
+26
View File
@@ -0,0 +1,26 @@
import type { TemplateDefinition } from "../contract";
import { ClassicLayout } from "./Layout";
import { headerCols } from "./plan";
/**
* Voiced Standard: the original single-column invoice, rebuilt on the engine.
*
* Margins: top 40, sides 40, bottom 24 plus the 24 pt footer strip the page reserves inside the bottom
* padding (see InvoicePage), so the content bottom stays 48 pt above the page edge as in Phase 0 and
* the footer text sits 24 pt above it. The narrow (36) and normal (54) presets only ever widen a side.
*/
export const classic: TemplateDefinition = {
id: "classic",
name: "Voiced Standard",
family: "classic",
version: 1,
margins: { top: 40, right: 40, bottom: 24, left: 40 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
logo: {
box: { w: 170, h: 54 },
stackBelowTextWidth: 160,
defaultOn: true,
slotWidth: (frame) => headerCols(frame.contentWidth)[0],
},
Layout: ClassicLayout,
};
+157
View File
@@ -0,0 +1,157 @@
import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer } from "../../engine/measure";
import { textHeight } from "../../engine/textplan";
import { roleToken } from "../../fonts/roles";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { guardPresence } from "../../blocks/TableGuard";
import { signatoryHeight, Slot, slotHeights } from "../../blocks/slots";
import type { RenderModel } from "../../model/build";
export const INK = "#161616";
export const INK_SUB = "#393939";
export const MUTED = "#525252";
export const RULE = "#e0e0e0";
export const FINE = "#6f6f6f";
export const CANCELLED_INK = "#a2191f";
export const META_W = 192;
export const HEADER_GAP = 24;
export const SUPPLIER_MIN_TEXT = 160;
export const META_LABEL_W = 66;
export const TOTALS_W = 250;
export const TOTALS_AMOUNT_W = 130;
export const SIGNATURE_W = 200;
export const CLOSING_GAP_COLS = 16;
export const TOTALS_ROW_PAD = 2.5;
export const TABLE_GAP = 4;
export const TABLE_PAD_Y = 6;
export const TABLE_BORDER = 0.6;
/** The header row's extra 1.4 pt top rule, which planItemsTable does not count. */
export const TABLE_HEAD_TOP_RULE = 1.4;
export const GRAND_RULE = 1.4;
/** Supplier column and meta column of the header row: the extra width on Letter goes to the supplier. */
export const headerCols = (contentWidth: number): [number, number] => {
const [supplier, meta] = cols(contentWidth, [{ fill: true }, { fixed: META_W }], HEADER_GAP);
return [supplier, meta];
};
/**
* Elastic vertical gaps, [nominal, minimum] in pt. On Letter the page is 49.89 pt shorter than A4;
* the gaps shrink together (each by the same share of its slack) to give that height back before
* anything flows onto the next page. The slack totals 60 pt, so the whole deficit is absorbed. Type
* is never scaled.
*/
const GAP_RANGES = {
dividerTop: [16, 8],
dividerBottom: [16, 8],
table: [22, 12],
totals: [12, 6],
words: [12, 6],
notes: [14, 8],
closing: [34, 18],
} as const satisfies Record<string, readonly [number, number]>;
export type Gaps = { -readonly [K in keyof typeof GAP_RANGES]: number };
export function elasticGaps(frame: Pick<PageFrame, "heightDeficit">): Gaps {
const keys = Object.keys(GAP_RANGES) as Array<keyof typeof GAP_RANGES>;
const slack = keys.reduce((sum, k) => sum + GAP_RANGES[k][0] - GAP_RANGES[k][1], 0);
const share = slack > 0 ? Math.min(1, Math.max(0, frame.heightDeficit) / slack) : 0;
const out = {} as Gaps;
for (const k of keys) {
const [nominal, min] = GAP_RANGES[k];
out[k] = Math.round((nominal - share * (nominal - min)) * 100) / 100;
}
return out;
}
export interface ClassicPlan {
gaps: Gaps;
columns: ColSpec[];
header: string[];
rows: TableCellValue[][];
align: Array<"left" | "right">;
padRight: number[];
table: ItemsTablePlan;
guardH: number;
tailH: number;
bankW: number;
closingH: number;
}
const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leading as number;
/**
* Everything the layout must know before rendering: the table plan, and the predicted heights of the
* totals tail and the closing block, which become minPresenceAhead values. Each prediction is
* deliberately a hair high (+1): too low and a block can start a page without its spacer.
*/
export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan {
const gaps = elasticGaps(frame);
const hsn = Slot.HsnSac(model);
const columns: ColSpec[] = [
{ fixed: 28 },
{ fill: true, min: 160 },
...(hsn ? [{ fixed: 62 } as ColSpec] : []),
{ fixed: 92 },
{ fixed: 40 },
{ fixed: 88 },
];
const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"];
const align: ClassicPlan["align"] = ["left", "left", ...(hsn ? (["left"] as const) : []), "right", "right", "right"];
// Description text stays 6 pt clear of the next column (Phase 0 had an 8 pt right padding there).
const padRight = [0, 6, ...(hsn ? [0] : []), 0, 0, 0];
const rows: TableCellValue[][] = model.items.map((item, i) => [
String(item.sno),
item.descriptionLines,
...(hsn ? [hsn.values[i]] : []),
item.rateText ?? "—",
item.qtyText,
item.amountText,
]);
const t = model.totals;
const totalsRows = 1 + (t.discountText ? 1 : 0) + (t.taxableText ? 1 : 0) + slotHeights.taxRowCount(model);
const rowH = leading("body") + 2 * TOTALS_ROW_PAD;
const grandH = 4 + GRAND_RULE + 6 + leading("h2");
let tailH = gaps.totals + totalsRows * rowH + grandH;
const wordsH = slotHeights.amountInWords(model, frame.contentWidth);
if (wordsH) tailH += gaps.words + wordsH;
const reverseH = slotHeights.reverseCharge(model, frame.contentWidth);
if (reverseH) tailH += 4 + reverseH;
tailH += 1;
const table = planItemsTable({
columns,
gap: TABLE_GAP,
width: frame.contentWidth,
header,
rows,
measurer: getMeasurer(),
roles: { head: "tableHead", body: "tableBody" },
padY: TABLE_PAD_Y,
border: TABLE_BORDER,
padRight,
tailH,
contentH: frame.contentHeight,
});
const guardH = guardPresence(table.headH + TABLE_HEAD_TOP_RULE + 1, table.firstRowH, leading("tableBody"));
const [bankW] = cols(frame.contentWidth, [{ fill: true }, { fixed: SIGNATURE_W }], CLOSING_GAP_COLS);
let bankH = 0;
if (model.bank) {
bankH = leading("label") + 3 + textHeight("bodyStrong", model.bank.accountName, bankW);
for (const l of model.bank.lines) bankH += textHeight("body", l.label === "Bank" ? l.value : `${l.label}: ${l.value}`, bankW);
}
const sigH = signatoryHeight(model, SIGNATURE_W);
let closingH = gaps.closing + Math.max(bankH, sigH);
if (model.cancelled) closingH += 10 + textHeight("small", CANCELLED_SENTENCE, frame.contentWidth);
closingH += 1.5;
return { gaps, columns, header, rows, align, padRight, table, guardH, tailH, bankW, closingH };
}
export const CANCELLED_SENTENCE = "This invoice has been cancelled.";
+46
View File
@@ -0,0 +1,46 @@
import type { ComponentType } from "react";
import { effectiveMargins, type MarginPreset, type PageFrame, type Sides } from "../engine/geometry";
import type { RenderModel } from "../model/build";
import type { RenderPrefsV1 } from "../model/prefs";
/** A logo after fitting and the stack-or-side-by-side decision (engine/logo.ts). */
export interface PlacedLogo {
dataUri: string;
w: number;
h: number;
stacked: boolean;
}
export interface TemplateProps {
model: RenderModel;
frame: PageFrame;
logo: PlacedLogo | null;
prefs: RenderPrefsV1;
}
export interface TemplateDefinition {
id: string;
name: string;
family: string;
/** Bump when the layout changes in a way that alters pages; it goes into the PDF Creator string. */
version: number;
/** The template's own physical margins (the "template" margin preset). */
margins: Sides;
/** No margin preset may go below this, per side; the printer-safe floor still applies on top. */
marginFloor: Sides;
logo: {
/** The largest box the logo may fill, pt. */
box: { w: number; h: number };
/** The logo moves above the text when the text beside it would be narrower than this. */
stackBelowTextWidth: number;
defaultOn: boolean;
/** Width of the column the logo shares with the supplier text, for this frame. */
slotWidth: (frame: PageFrame) => number;
};
Layout: ComponentType<TemplateProps>;
}
/** Physical margins for a template under a margin preset. */
export function marginsFor(template: TemplateDefinition, preset: MarginPreset): Sides {
return effectiveMargins(preset === "template" ? template.margins : preset, template.marginFloor);
}
+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));
}
});
}
});
+302
View File
@@ -0,0 +1,302 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { deflateSync } from "node:zlib";
import { computeTotals } from "../../lib/invoice";
import { amountInWords } from "../../lib/numberToWords";
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "../../lib/types";
import { buildRenderModel, type LogoInput, type RenderModel } from "../model/build";
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../model/prefs";
import { renderInvoicePdf, type RenderInvoiceResult } from "../render/invoice";
import { parsePdfText, type RenderedPdf } from "../testing/pdfText";
import { setupPdfTest } from "../testing/setup";
/** Fixtures and helpers shared by the template harness and the sample renderer. */
export type LogoKind = "none" | "real" | "wide" | "square";
export interface FixtureSpec {
name: string;
vendor?: Partial<Settings>;
invoice?: Partial<Invoice>;
/** An item list, or a count of generated one-line items. */
items?: InvoiceItem[] | number;
logo?: LogoKind;
signature?: boolean;
/** Tax invoice with a registered supplier (default true; false gives the plain invoice). */
registered?: boolean;
}
export const FIXTURE_GSTIN = "29AAAAA0000A1Z5";
const FIXTURE_CLIENT_GSTIN = "29ABCDE1234F1Z5";
const baseVendor: Settings = {
vendorName: "Test Vendor",
vendorAddress: "1 Test Road, Bangalore - 560001",
vendorEmail: "vendor@example.com",
vendorPhone: "9000000000",
vendorPan: "ABCDE1234F",
vendorGstin: "",
vendorStateCode: "29",
logoPath: null,
signaturePath: null,
defaultBankId: 1,
defaultTaxRate: 18,
defaultTaxType: "cgst_sgst",
paymentTermsDays: 30,
currency: "INR",
onboarded: true,
theme: "g10",
gstRegistration: "unregistered",
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
};
export const FIXTURE_BANK: BankAccount = {
id: 1,
label: "Primary",
bankName: "State Bank of India",
accountName: "Test Vendor",
accountNo: "000000000000",
branch: "000001",
ifsc: "TEST0000001",
isDefault: true,
};
export const makeItems = (n: number, hsn: boolean): InvoiceItem[] =>
Array.from({ length: n }, (_, i) => ({
id: i + 1,
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
mode: i % 2 === 0 ? "rate" : "fixed",
rate: 1200,
unit: "hour",
quantity: 1 + (i % 3),
amount: 2500 + i * 10,
sortOrder: i,
...(hsn ? { hsnSac: "998397" } : {}),
}));
// --- tiny PNG writer: logos for the fixtures without a native image library or a binary fixture ----
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc32 = (buf: Buffer) => {
let c = 0xffffffff;
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type: string, data: Buffer) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
};
/** A w x h RGBA PNG: a navy block with a light stripe, so a wrongly stretched logo is visible. */
export function solidPng(w: number, h: number): Buffer {
const row = Buffer.alloc(1 + w * 4);
const raw = Buffer.alloc(row.length * h);
for (let y = 0; y < h; y++) {
raw[y * row.length] = 0;
for (let x = 0; x < w; x++) {
const stripe = Math.abs(x - y * (w / h)) < h * 0.12;
const o = y * row.length + 1 + x * 4;
raw[o] = stripe ? 120 : 24;
raw[o + 1] = stripe ? 160 : 52;
raw[o + 2] = stripe ? 240 : 120;
raw[o + 3] = 255;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8;
ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
const dataUri = (png: Buffer) => `data:image/png;base64,${png.toString("base64")}`;
const cache = new Map<string, LogoInput>();
export function fixtureLogo(kind: LogoKind): LogoInput | null {
if (kind === "none") return null;
let hit = cache.get(kind);
if (!hit) {
if (kind === "real") {
// 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect.
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"));
hit = { dataUri: dataUri(png), width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
} else if (kind === "wide") {
// The trimmed Test Vendor ink box is 4.6:1.
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100 };
} else {
hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120 };
}
cache.set(kind, hit);
}
return hit;
}
let signature: string | null = null;
const signatureUri = () => (signature ??= dataUri(solidPng(320, 90)));
export interface BuiltFixture {
spec: FixtureSpec;
model: RenderModel;
invoice: Invoice;
}
/** A full `Invoice` read model (totals derived the way Rust does) pushed through buildRenderModel. */
export function buildFixture(spec: FixtureSpec): BuiltFixture {
const registered = spec.registered !== false;
const taxInvoice = registered;
const items = Array.isArray(spec.items) ? spec.items : makeItems(spec.items ?? 3, taxInvoice);
const taxType: TaxType = spec.invoice?.taxType ?? (taxInvoice ? "cgst_sgst" : "none");
const taxRate = spec.invoice?.taxRate ?? (taxInvoice ? 18 : 0);
const discount = spec.invoice?.discount ?? 0;
const totals = computeTotals(items, discount, taxType, taxRate);
const vendor: Settings = {
...baseVendor,
...(registered
? { vendorGstin: FIXTURE_GSTIN, gstRegistration: "regular" as const, signatoryName: "Test Vendor", signatoryDesignation: "Proprietor" }
: // A stale GSTIN left in the settings of an unregistered supplier must never print.
{ vendorGstin: "27ZZZZZ9999Z1Z9", gstRegistration: "unregistered" as const }),
...spec.vendor,
};
const pos = vendor.vendorStateCode;
const invoice: Invoice = {
id: 1,
number: "INV/2026-001",
seriesId: 1,
invoiceDate: "2026-09-15",
dueDate: "2026-10-15",
clientId: null,
clientName: "Acme Learning Pvt Ltd",
clientAddress: "42 MG Road\nBangalore 560001",
clientGstin: FIXTURE_CLIENT_GSTIN,
poNumber: "",
placeOfSupplyStateCode: taxInvoice ? pos : "",
subtotal: totals.subtotal,
discount: totals.discount,
taxType,
taxRate,
cgstAmount: totals.cgst,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: amountInWords(totals.total),
bankAccountId: 1,
bankSnapshot: "",
signaturePath: null,
notes: "",
status: "issued",
createdAt: "2026-09-15T00:00:00Z",
updatedAt: "2026-09-15T00:00:00Z",
docType: taxInvoice ? "tax_invoice" : "invoice",
reverseCharge: false,
vendorSnapshot: "",
snapshotOrigin: "",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
items,
...spec.invoice,
};
const model = buildRenderModel({
invoice,
vendor,
bank: FIXTURE_BANK,
logo: fixtureLogo(spec.logo ?? "real"),
signatureDataUri: spec.signature ? signatureUri() : null,
appVersion: "0.1.0",
});
return { spec, model, invoice };
}
const LONG_ADDRESS =
"Unit 4, Third Floor, The Grand Heritage Business Centre, Plot 1187-B, Old Airport Road Extension, Opposite the Municipal Corporation Ward Office, Domlur Second Stage, Bengaluru Urban District, Karnataka State, India - 560071; Registered office: Level 12, Tower C, Global Technology Park, Outer Ring Road, Marathahalli, Bengaluru - 560103";
/** The fixture matrix of the template harness. Every name is unique; the file name of a sample uses it. */
export const FIXTURES: FixtureSpec[] = [
{ name: "minimal", items: 1, logo: "none" },
{ name: "long-address", vendor: { vendorAddress: LONG_ADDRESS }, items: 4 },
{
name: "id-tokens",
vendor: {
vendorEmail: "Sample.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in",
vendorPhone: "+91 97382 84459 / +91 80 4123 9988 (studio)",
vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt",
},
invoice: { clientName: "https://www.extremely-long-client-website-address.example.com/projects/2026/voice", clientGstin: "29ABCDE1234F1Z5", poNumber: "PO/2026/BANGALORE/0042/REV-3/LONG-REFERENCE" },
items: 3,
},
{ name: "rows-60", items: 60, invoice: { notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." }, signature: true },
{
name: "multiline",
items: [
{ description: "Voice-over for the onboarding course\nModule 1 - introduction\nModule 2 - safety basics\nIncludes two rounds of revisions", mode: "rate", rate: 1500, unit: "hour", quantity: 3, amount: 0, sortOrder: 0, hsnSac: "998397" },
{ description: "Studio hire", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 1, hsnSac: "997212" },
{ 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" },
],
},
{
name: "long-notes",
items: 30,
invoice: { notes: Array.from({ length: 18 }, (_, i) => `Note ${i + 1}: payment is due within thirty days of the invoice date; late payments attract interest at two percent per month.`).join("\n") },
},
{ name: "igst", invoice: { taxType: "igst", placeOfSupplyStateCode: "27", discount: 500 }, items: 5 },
{ name: "cgst-sgst", items: 5, invoice: { poNumber: "PO-2026-0042", notes: "Thank you for your business." } },
{ name: "utgst", vendor: { vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5" }, invoice: { placeOfSupplyStateCode: "04" }, items: 4 },
{ name: "unregistered", registered: false, items: 3 },
{ name: "cancelled", invoice: { status: "cancelled" }, items: 4, signature: true },
{ name: "logo-none", logo: "none", items: 3 },
{ name: "logo-wide", logo: "wide", items: 3 },
{ name: "logo-square", logo: "square", items: 3 },
{
name: "long-client",
invoice: {
clientName: "The Board of Trustees of the Mahatma Gandhi Memorial Institute of Education, Research and Rural Development (Registered Society)",
clientAddress: "Administrative Block, Level 3, Institutional Area, Sector 62\nNoida, Gautam Buddha Nagar, Uttar Pradesh - 201309",
},
items: 3,
},
];
export const PAGE_SIZE_IDS = ["A4", "LETTER"] as const;
export const prefsFor = (pageSize: RenderPrefsV1["pageSize"], over: Partial<RenderPrefsV1> = {}): RenderPrefsV1 => ({ ...DEFAULT_PREFS, pageSize, ...over });
export interface Rendered {
fixture: BuiltFixture;
prefs: RenderPrefsV1;
result: RenderInvoiceResult;
pdf: RenderedPdf;
}
let ready = false;
/** Registers fonts and installs the measurer once per process. */
export function ensureSetup(): void {
if (!ready) {
setupPdfTest();
ready = true;
}
}
export async function renderFixture(spec: FixtureSpec, prefs: RenderPrefsV1): Promise<Rendered> {
ensureSetup();
const fixture = buildFixture(spec);
const result = await renderInvoicePdf({ model: fixture.model, prefs });
const pdf = await parsePdfText(result.bytes);
return { fixture, prefs, result, pdf };
}
+13
View File
@@ -0,0 +1,13 @@
import type { TemplateDefinition } from "./contract";
import { classic } from "./classic";
export const TEMPLATES: Record<string, TemplateDefinition> = {
classic,
};
export const DEFAULT_TEMPLATE_ID = "classic";
/** The template for an id; an unknown id (a template removed later, a corrupt pref) falls back to Classic. */
export function getTemplate(id: string | undefined): TemplateDefinition {
return (id !== undefined ? TEMPLATES[id] : undefined) ?? TEMPLATES[DEFAULT_TEMPLATE_ID];
}
+51
View File
@@ -0,0 +1,51 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const TEMPLATES_DIR = path.join(ROOT, "src/pdf/templates");
/**
* Model fields that carry regulated data. Templates must not read them: the data may leave the model
* only through the Slot components, so the contract test can see every place it is printed.
*/
const REGULATED = [
/\.gstin\b/,
/\.showGstin\b/,
/\.posLabel\b/,
/\.hsnSac\b/,
/\.taxRows\b/,
/\.amountInWords\b/,
/\.reverseCharge\b/,
/\.signature\b/,
/\.slots\b/,
];
function sources(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return sources(full);
return /\.(ts|tsx)$/.test(name) && !/\.test\.|harness\.ts$/.test(name) ? [full] : [];
});
}
describe("templates read regulated data only through Slot.*", () => {
it("scans the classic template", () => {
expect(sources(TEMPLATES_DIR).some((f) => f.endsWith("classic/Layout.tsx"))).toBe(true);
});
it("finds no direct access to the regulated model fields", () => {
const hits: string[] = [];
for (const file of sources(TEMPLATES_DIR)) {
readFileSync(file, "utf8")
.split("\n")
.forEach((line, i) => {
if (/^\s*(\/\/|\*|\/\*)/.test(line)) return;
// The slot API itself is the sanctioned way in: Slot.TaxRows, slotHeights.amountInWords, ...
const code = line.replace(/\b(Slot|slotHeights)\.\w+/g, "");
for (const re of REGULATED) if (re.test(code)) hits.push(`${path.relative(ROOT, file)}:${i + 1} ${line.trim()}`);
});
}
expect(hits).toEqual([]);
});
});