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
+1 -1
View File
@@ -1,7 +1,7 @@
import { UNIT_LABELS } from "../lib/types"; import { UNIT_LABELS } from "../lib/types";
import { computeLineAmount } from "../lib/invoice"; import { computeLineAmount } from "../lib/invoice";
import { formatAmount, formatDate } from "../lib/format"; import { formatAmount, formatDate } from "../lib/format";
import type { InvoicePdfProps } from "../pdf/InvoiceDocument"; import type { InvoicePdfProps } from "../lib/pdfProps";
const money = (n: number) => `₹${formatAmount(n)}`; const money = (n: number) => `₹${formatAmount(n)}`;
+46 -27
View File
@@ -1,23 +1,23 @@
import { pdf } from "@react-pdf/renderer";
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { registerPdfFonts } from "../pdf/fonts"; import pkg from "../../package.json";
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument"; import { DEFAULT_PREFS, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel } from "../pdf/model/build";
import { bankFromSnapshot, vendorFromSnapshot } from "../pdf/model/vendor"; import { bankFromSnapshot, vendorFromSnapshot } from "../pdf/model/vendor";
import { initBrowserRendering } from "../pdf/render/browserInit";
import { renderInvoicePdf as renderModelPdf, type RenderInvoiceResult } from "../pdf/render/invoice";
import { api } from "./api"; import { api } from "./api";
import { imageSizeFromDataUri } from "./imageSize";
import type { InvoicePdfProps } from "./pdfProps";
import type { BankAccount, Invoice, Settings } from "./types"; import type { BankAccount, Invoice, Settings } from "./types";
function blobToBase64(blob: Blob): Promise<string> { function bytesToBase64(bytes: Uint8Array): string {
return new Promise((resolve, reject) => { let bin = "";
const reader = new FileReader(); const step = 0x8000;
reader.onload = () => { for (let i = 0; i < bytes.length; i += step) bin += String.fromCharCode(...bytes.subarray(i, i + step));
const result = String(reader.result ?? ""); return btoa(bin);
resolve(result.substring(result.indexOf(",") + 1));
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
} }
/** Flat props for the on-screen preview; the PDF itself is built by renderInvoicePdf. */
export async function buildPdfProps( export async function buildPdfProps(
invoice: Invoice, invoice: Invoice,
settings: Settings, settings: Settings,
@@ -60,26 +60,44 @@ export async function buildPdfProps(
}; };
} }
/** Render props straight to a PDF blob. */ /** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> { const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
registerPdfFonts();
return pdf(<InvoiceDocument {...props} />).toBlob(); /**
* Renders an issued invoice through the engine: model (supplier and bank from the snapshots frozen at
* issue, live settings only as a fallback) -> frame -> template -> PDF bytes, with the layout audit
* issues and fingerprint.
*/
export async function renderInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = DEFAULT_PREFS,
): Promise<RenderInvoiceResult> {
await initBrowserRendering();
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
const model = buildRenderModel({
invoice,
vendor: settings,
bank,
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
signatureDataUri,
appVersion: pkg.version,
});
return renderModelPdf({ model, prefs });
} }
/** Render a PDF and prompt the user for a save location. */ /** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
export async function exportPdfFromProps( export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise<string | null> {
props: InvoicePdfProps,
fileName: string,
): Promise<string | null> {
const blob = await renderPdfBlob(props);
const dataBase64 = await blobToBase64(blob);
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice"; const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
const path = await save({ const path = await save({
defaultPath: `${suggested}.pdf`, defaultPath: `${suggested}.pdf`,
filters: [{ name: "PDF document", extensions: ["pdf"] }], filters: [{ name: "PDF document", extensions: ["pdf"] }],
}); });
if (!path) return null; if (!path) return null;
await api.saveBinaryFile(path, dataBase64); await api.saveBinaryFile(path, bytesToBase64(bytes));
return path; return path;
} }
@@ -87,7 +105,8 @@ export async function exportInvoicePdf(
invoice: Invoice, invoice: Invoice,
settings: Settings, settings: Settings,
bank: BankAccount | null, bank: BankAccount | null,
prefs: RenderPrefsV1 = DEFAULT_PREFS,
): Promise<string | null> { ): Promise<string | null> {
const props = await buildPdfProps(invoice, settings, bank); const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
return exportPdfFromProps(props, invoice.number); return savePdfBytes(bytes, invoice.number);
} }
+31
View File
@@ -0,0 +1,31 @@
import type { BankAccount, InvoiceItem, Settings, TaxType } from "./types";
/**
* The flat invoice props the on-screen preview (components/InvoicePreview.tsx) renders from. The PDF no
* longer uses them: it goes invoice -> buildRenderModel -> template (see lib/pdf.tsx).
*/
export interface InvoicePdfProps {
vendor: Settings;
bank: BankAccount | null;
logoDataUri: string | null;
signatureDataUri: string | null;
number: string;
invoiceDate: string;
dueDate: string;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
items: InvoiceItem[];
subtotal: number;
discount: number;
taxType: TaxType;
taxRate: number;
cgst: number;
sgst: number;
igst: number;
total: number;
amountInWords: string;
notes: string;
}
-294
View File
@@ -1,294 +0,0 @@
import { createElement } from "react";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { InvoiceDocument, type InvoicePdfProps } from "./InvoiceDocument";
import { registerPdfFonts } from "./fonts";
import { assertNoOverlap } from "./testing/asserts";
import { describeBox, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText";
import type { BankAccount, InvoiceItem, Settings } from "../lib/types";
registerPdfFonts(path.resolve(process.cwd(), "public/fonts"));
/** Set PDF_OUT_DIR to also write the rendered PDFs there for visual review. */
const OUT_DIR = process.env.PDF_OUT_DIR ?? "";
const logoDataUri = `data:image/png;base64,${readFileSync(
path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"),
).toString("base64")}`;
const vendor: 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: "",
};
const bank: BankAccount = {
id: 1,
label: "Primary",
bankName: "State Bank of India",
accountName: "Test Vendor",
accountNo: "000000000000",
branch: "000001",
ifsc: "TEST0000001",
isDefault: true,
};
const makeItems = (n: number): 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,
}));
const round2 = (n: number) => Math.round(n * 100) / 100;
const lineAmount = (it: InvoiceItem) => (it.mode === "rate" ? it.rate * it.quantity : it.amount);
function build(over: Partial<InvoicePdfProps> & { items: InvoiceItem[] }): InvoicePdfProps {
const subtotal = round2(over.items.reduce((s, it) => s + lineAmount(it), 0));
const discount = over.discount ?? 0;
const taxType = over.taxType ?? "cgst_sgst";
const taxRate = over.taxRate ?? 18;
const taxable = subtotal - discount;
const tax = taxType === "none" ? 0 : round2((taxable * taxRate) / 100);
const cgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
const sgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
const igst = taxType === "igst" ? tax : 0;
return {
vendor,
bank,
logoDataUri,
signatureDataUri: null,
number: "INV-0001",
invoiceDate: "2026-09-15",
dueDate: "2026-10-15",
clientName: "Acme Learning Pvt Ltd",
clientAddress: "42 MG Road\nBangalore 560001",
clientGstin: "29ABCDE1234F1Z5",
poNumber: "",
placeOfSupplyStateCode: "29",
subtotal,
discount,
taxType,
taxRate,
cgst,
sgst,
igst,
total: round2(taxable + cgst + sgst + igst),
amountInWords: "Rupees One Lakh Only",
notes: "",
...over,
};
}
const fixtureA = () =>
build({
items: makeItems(3),
notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest.",
poNumber: "PO-2026-0042",
});
const fixtureB = () => build({ items: makeItems(60) });
const fixtureD = () =>
build({
items: makeItems(5),
logoDataUri: null,
signatureDataUri: null,
vendor: {
...vendor,
vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt",
vendorEmail: "vendor.name@examplestudios.co.in",
vendorAddress: "13-B type House, Test Road\nTest Town, Cooke Town Extension\nBangalore - 560005",
},
clientAddress: "42 MG Road, Ashok Nagar\nBangalore 560001",
taxType: "igst",
taxRate: 18,
discount: 500,
});
const render = (p: InvoicePdfProps) => renderPdfText(createElement(InvoiceDocument, p));
const save = (name: string, pdf: RenderedPdf) => {
if (!OUT_DIR) return;
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(path.join(OUT_DIR, name), pdf.bytes);
};
const MARGIN_L = 39.5;
const MARGIN_R = 555.8;
const find = (items: TextBox[], str: string) => items.find((t) => t.str.trim() === str);
/** Letter-spaced labels come out of pdf.js with spaces between glyph runs ("D E S C R I P TI O N"). */
const findSpaced = (items: TextBox[], str: string) =>
items.find((t) => t.str.replace(/\s+/g, "").toUpperCase() === str.toUpperCase());
const textOf = (items: TextBox[]) => items.map((t) => t.str).join(" ");
/** Assertions that apply to every page of every fixture. */
function checkCommon(pdf: RenderedPdf) {
const n = pdf.pages.length;
expect(pdf.raw).not.toContain("/BaseFont /Helvetica");
assertNoOverlap(pdf.pages);
pdf.pages.forEach((page, idx) => {
const k = idx + 1;
for (const t of page.items) {
const isFooter = t.top > 780;
expect(t.x0, `page ${k} ${describeBox(t)} left`).toBeGreaterThanOrEqual(MARGIN_L);
expect(t.x1, `page ${k} ${describeBox(t)} right`).toBeLessThanOrEqual(MARGIN_R);
if (isFooter) expect(t.bottom, `page ${k} ${describeBox(t)} bottom`).toBeLessThanOrEqual(842);
else expect(t.top, `page ${k} ${describeBox(t)} top`).toBeGreaterThanOrEqual(MARGIN_L);
}
const label = `Page ${k} of ${n}`;
const pageLabels = page.items.filter((t) => /^Page \d+ of \d+$/.test(t.str.trim()));
expect(pageLabels.map((t) => t.str.trim()), `page ${k} page label`).toEqual([label]);
expect(
page.items.filter((t) => t.str.includes("Generated with Voiced")).length,
`page ${k} footer`,
).toBe(1);
const text = textOf(page.items);
expect(text).not.toContain("¹");
});
}
function checkHeader(pdf: RenderedPdf, hasDue = true) {
const { items } = pdf.pages[0];
const metaStrs = ["Invoice No.", "Date", ...(hasDue ? ["Due Date"] : [])];
const metaIdx = items.filter((t) => t.top < 150 && (t.str.trim() === "INVOICE" || /TAX INVOICE/i.test(t.str)));
const title = metaIdx[0] ?? find(items, "INVOICE");
expect(title, "title").toBeDefined();
const meta: TextBox[] = [title as TextBox];
for (const s of metaStrs) {
const lab = find(items, s);
expect(lab, s).toBeDefined();
meta.push(lab as TextBox);
// value: item on the same baseline to the right of the label
const val = items.find((t) => t !== lab && Math.abs(t.bottom - (lab as TextBox).bottom) < 3 && t.x0 > (lab as TextBox).x1 - 0.5);
expect(val, `${s} value`).toBeDefined();
meta.push(val as TextBox);
}
for (const m of meta) expect(m.x0, describeBox(m)).toBeGreaterThanOrEqual(362.5);
const headerBottom = Math.max(...meta.map((m) => m.bottom));
for (const t of items) {
if (meta.includes(t) || t.top > headerBottom + 20) continue;
if (t.x0 < 339.5) expect(t.x1, `header left ${describeBox(t)}`).toBeLessThanOrEqual(339.5);
}
}
function checkRupee(pdf: RenderedPdf) {
const all = pdf.pages.flatMap((p) => p.items);
const total = all.filter((t) => t.str.includes("Subtotal") || t.str.trim() === "Total");
expect(total.length).toBeGreaterThan(0);
const amounts = all.filter((t) => /\d\.\d\d$/.test(t.str.trim()));
expect(amounts.length).toBeGreaterThan(0);
for (const a of amounts) expect(a.str, describeBox(a)).toContain("₹");
}
/** Table-structure checks for multi-page documents. */
function checkTable(pdf: RenderedPdf) {
let totalPage = -1;
let subtotalPage = -1;
pdf.pages.forEach((page, idx) => {
const k = idx + 1;
const head = findSpaced(page.items, "DESCRIPTION");
const isTotals = (t: TextBox) => {
// amounts at or below the totals block start
const sub = find(page.items, "Subtotal");
return !!sub && t.top >= sub.top - 1;
};
const rows = page.items.filter(
(t) => /^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.x0 > 440 && !isTotals(t) && (!head || t.top > head.top),
);
if (head) {
expect(rows.length, `page ${k} has header but no rows`).toBeGreaterThan(0);
}
if (idx > 0 && rows.length > 0) expect(head, `page ${k} header repeats`).toBeDefined();
if (find(page.items, "Subtotal")) subtotalPage = k;
if (find(page.items, "Total")) totalPage = k;
});
expect(subtotalPage).toBeGreaterThan(0);
expect(totalPage, "Subtotal and Total on same page").toBe(subtotalPage);
}
describe("InvoiceDocument fixture A (3 items, stacked logo)", () => {
let pdf: RenderedPdf;
beforeAll(async () => {
pdf = await render(fixtureA());
save("new-A.pdf", pdf);
});
it("is a single page", () => expect(pdf.pages.length).toBe(1));
it("has no overlaps, margin breaches, stray Helvetica, page labels or footer gaps", () => checkCommon(pdf));
it("keeps header columns apart", () => checkHeader(pdf));
it("prints rupee signs", () => checkRupee(pdf));
it("has table structure", () => checkTable(pdf));
});
describe("InvoiceDocument fixture B (60 items)", () => {
let pdf: RenderedPdf;
beforeAll(async () => {
pdf = await render(fixtureB());
save("new-B-60rows.pdf", pdf);
});
it("spans multiple pages", () => expect(pdf.pages.length).toBeGreaterThan(1));
it("common checks", () => checkCommon(pdf));
it("header", () => checkHeader(pdf));
it("rupee", () => checkRupee(pdf));
it("table", () => checkTable(pdf));
});
describe("InvoiceDocument fixture C (1..45 items sweep)", () => {
it("never strands a header and always keeps Subtotal/Total together", async () => {
for (let n = 1; n <= 45; n++) {
const pdf = await render(build({ items: makeItems(n) }));
try {
checkCommon(pdf);
checkHeader(pdf);
checkRupee(pdf);
checkTable(pdf);
} catch (e) {
throw new Error(`items=${n} (${pdf.pages.length} pages): ${(e as Error).message}`);
}
}
});
});
describe("InvoiceDocument fixture D (long vendor strings, no logo, IGST, discount)", () => {
let pdf: RenderedPdf;
beforeAll(async () => {
pdf = await render(fixtureD());
save("new-D.pdf", pdf);
});
it("common checks", () => checkCommon(pdf));
it("header", () => checkHeader(pdf));
it("rupee", () => checkRupee(pdf));
it("table", () => checkTable(pdf));
it("shows IGST and discount", () => {
const text = textOf(pdf.pages.flatMap((p) => p.items));
expect(text).toContain("IGST");
expect(text).toContain("Discount");
});
});
-450
View File
@@ -1,450 +0,0 @@
import { Document, Image, Page, StyleSheet, Text, View, type TextProps } from "@react-pdf/renderer";
import type { ComponentProps, PropsWithChildren } from "react";
import { UNIT_LABELS, type BankAccount, type InvoiceItem, type Settings, type TaxType } from "../lib/types";
import { computeLineAmount } from "../lib/invoice";
import { imageSizeFromDataUri, type ImageSize } from "../lib/imageSize";
const amountFmt = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const money = (n: number) => `${"₹"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
const dateFmt = (iso: string) => {
if (!iso) return "—";
const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
};
/** One entry per line, so no Text ever contains "\n" (react-pdf pulls in an unembedded Helvetica for it). */
const splitLines = (value: string): string[] =>
value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
export interface InvoicePdfProps {
vendor: Settings;
bank: BankAccount | null;
logoDataUri: string | null;
signatureDataUri: string | null;
number: string;
invoiceDate: string;
dueDate: string;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
items: InvoiceItem[];
subtotal: number;
discount: number;
taxType: TaxType;
taxRate: number;
cgst: number;
sgst: number;
igst: number;
total: number;
amountInWords: string;
notes: string;
}
/*
* Layout rules (see the rendering notes in the project plan):
* - Every child of a flex row has an explicit width (width = minWidth = maxWidth), or is the single
* fill child with { flexGrow: 1, flexBasis: 0 }. react-pdf wraps text once, at the first measure;
* a box that shrinks afterwards keeps its stale lines and the text overflows into its neighbour.
* - No lineHeight on the Page or on any View. Every static Text sets fontSize and an absolute
* lineHeight ("Npt") on the same node. The render-prop footer Text has no lineHeight anywhere above it.
* - Never pin the height of a box that holds text; textkit truncates to the box height.
*/
// A4 content box: 595.28 pt wide minus 2 x 40 pt page padding.
const CONTENT_W = 515;
const SUPPLIER_W = 299;
const HEADER_GAP = 24;
const META_W = CONTENT_W - SUPPLIER_W - HEADER_GAP;
const HALF_W = 245;
const LOGO_MAX_W = 170;
const LOGO_MAX_H = 54;
const LOGO_GAP = 14;
const MIN_TEXT_W = 160;
const pinned = (w: number) => ({ width: w, minWidth: w, maxWidth: w });
const styles = StyleSheet.create({
page: {
fontFamily: "IBM Plex Sans",
fontSize: 9.5,
color: "#161616",
paddingTop: 40,
paddingBottom: 48,
paddingHorizontal: 40,
},
body: { fontSize: 9.5, lineHeight: "13.8pt" },
header: { flexDirection: "row", alignItems: "flex-start" },
supplier: { ...pinned(SUPPLIER_W) },
supplierRow: { flexDirection: "row" },
supplierStacked: { flexDirection: "column" },
supplierTextRow: { flexGrow: 1, flexBasis: 0 },
supplierTextStacked: { ...pinned(SUPPLIER_W) },
vendorName: { fontSize: 18, lineHeight: "22pt", fontWeight: 600, marginBottom: 3 },
vendorLine: { color: "#393939" },
meta: { ...pinned(META_W), marginLeft: HEADER_GAP, textAlign: "right" },
// marginRight cancels the letterSpacing react-pdf adds after the last glyph, so the title's
// right edge lines up with the values below it.
title: {
fontSize: 18,
lineHeight: "22pt",
fontWeight: 600,
letterSpacing: 1.5,
textTransform: "uppercase",
marginBottom: 8,
marginRight: -1.5,
},
metaRow: { flexDirection: "row", alignItems: "flex-start" },
metaLabel: { ...pinned(66), textAlign: "left", color: "#525252" },
metaValue: { flexGrow: 1, flexBasis: 0, textAlign: "right", fontWeight: 600 },
divider: { borderBottomWidth: 1, borderBottomColor: "#e0e0e0", marginTop: 16, marginBottom: 16 },
split: { flexDirection: "row", gap: HEADER_GAP },
blockLabel: {
fontSize: 7.5,
lineHeight: "10pt",
letterSpacing: 1,
textTransform: "uppercase",
color: "#525252",
marginBottom: 3,
},
strong: { fontWeight: 600 },
half: { ...pinned(HALF_W) },
// No border or padding on the table container: with one, react-pdf can leave a page that holds
// only the repeated header row. The top rule belongs to the header row instead.
table: { marginTop: 22 },
tr: {
flexDirection: "row",
borderBottomWidth: 0.6,
borderBottomColor: "#e0e0e0",
paddingVertical: 6,
},
trHead: { borderTopWidth: 1.4, borderTopColor: "#161616", borderBottomColor: "#161616" },
th: {
fontSize: 7.5,
lineHeight: "10pt",
letterSpacing: 0.8,
textTransform: "uppercase",
color: "#525252",
paddingBottom: 4,
},
colDesc: { flex: 5, paddingRight: 8 },
colRate: { flex: 2, textAlign: "right" },
colQty: { flex: 2, textAlign: "right" },
colTotal: { flex: 2, textAlign: "right" },
totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 },
totals: { ...pinned(250) },
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
grand: {
flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 1.4,
borderTopColor: "#161616",
marginTop: 4,
paddingTop: 6,
},
grandText: { fontSize: 13, lineHeight: "17pt", fontWeight: 600 },
words: { marginTop: 12, fontStyle: "italic" },
notes: { marginTop: 14 },
bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginTop: 34 },
bank: { ...pinned(280) },
signature: { ...pinned(200), textAlign: "right" },
signatureImg: { maxHeight: 58, maxWidth: 190, objectFit: "contain", marginLeft: "auto" },
signatureName: {
marginTop: 4,
paddingTop: 3,
borderTopWidth: 0.8,
borderTopColor: "#8d8d8d",
fontWeight: 600,
},
fineprint: { fontSize: 7.5, lineHeight: "10.5pt", color: "#6f6f6f", textAlign: "right", marginTop: 6 },
// The footer holds the page-number render prop, so nothing in it sets a lineHeight and it has
// no fixed height: react-pdf multiplies a render-prop lineHeight again on every relayout, and a
// fixed-height box draws no text when the line is taller than the box.
footer: {
position: "absolute",
bottom: 24,
left: 40,
right: 40,
flexDirection: "row",
borderTopWidth: 0.6,
borderTopColor: "#e0e0e0",
paddingTop: 6,
},
footerLeft: {
...pinned(170),
fontSize: 7.5,
color: "#6f6f6f",
maxLines: 1,
textOverflow: "ellipsis",
},
footerCenter: { flexGrow: 1, flexBasis: 0, fontSize: 7.5, color: "#6f6f6f", textAlign: "center" },
footerRight: { ...pinned(90), fontSize: 7.5, color: "#6f6f6f", textAlign: "right" },
});
type TxtProps = PropsWithChildren<TextProps>;
/** Text with the body size and an absolute line height; later styles override both together. */
function Txt({ style, ...rest }: TxtProps) {
const extra = Array.isArray(style) ? style : style ? [style] : [];
return <Text {...rest} style={[styles.body, ...extra]} />;
}
interface LogoPlan {
stacked: boolean;
style: ComponentProps<typeof Image>["style"];
}
/**
* Size the logo from its own aspect inside a 170 x 54 pt box. When it would leave the supplier
* text less than 160 pt, the logo goes above the text instead of beside it.
*/
function planLogo(size: ImageSize | null): LogoPlan {
if (!size) {
return {
stacked: true,
style: {
...pinned(LOGO_MAX_W),
height: LOGO_MAX_H,
objectFit: "contain",
objectPositionX: 0,
marginBottom: 8,
},
};
}
const aspect = size.width / size.height;
const h = Math.min(LOGO_MAX_H, LOGO_MAX_W / aspect);
const w = h * aspect;
const stacked = SUPPLIER_W - w - LOGO_GAP < MIN_TEXT_W;
return {
stacked,
style: {
...pinned(w),
height: h,
objectFit: "contain",
marginRight: stacked ? 0 : LOGO_GAP,
marginBottom: stacked ? 8 : 0,
},
};
}
export function InvoiceDocument(props: InvoicePdfProps) {
const {
vendor,
bank,
logoDataUri,
signatureDataUri,
number,
invoiceDate,
dueDate,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupplyStateCode,
items,
subtotal,
discount,
taxType,
taxRate,
cgst,
sgst,
igst,
total,
amountInWords,
notes,
} = props;
const isTaxInvoice = taxType !== "none";
const logo = logoDataUri ? planLogo(imageSizeFromDataUri(logoDataUri)) : null;
return (
<Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View style={[styles.supplier, logo?.stacked ? styles.supplierStacked : styles.supplierRow]}>
{logoDataUri && logo ? <Image style={logo.style} src={logoDataUri} /> : null}
<View style={logo?.stacked ? styles.supplierTextStacked : styles.supplierTextRow}>
<Txt style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Txt>
{splitLines(vendor.vendorAddress).map((line, i) => (
<Txt key={`addr-${i}`} style={styles.vendorLine}>
{line}
</Txt>
))}
{vendor.vendorEmail ? <Txt style={styles.vendorLine}>{vendor.vendorEmail}</Txt> : null}
{vendor.vendorPhone ? <Txt style={styles.vendorLine}>{vendor.vendorPhone}</Txt> : null}
{vendor.vendorPan ? <Txt style={styles.vendorLine}>PAN: {vendor.vendorPan}</Txt> : null}
{vendor.vendorGstin ? <Txt style={styles.vendorLine}>GSTIN: {vendor.vendorGstin}</Txt> : null}
</View>
</View>
<View style={styles.meta}>
<Txt style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Txt>
<View style={styles.metaRow}>
<Txt style={styles.metaLabel}>Invoice No.</Txt>
<Txt style={styles.metaValue}>{number}</Txt>
</View>
<View style={styles.metaRow}>
<Txt style={styles.metaLabel}>Date</Txt>
<Txt style={styles.metaValue}>{dateFmt(invoiceDate)}</Txt>
</View>
{dueDate ? (
<View style={styles.metaRow}>
<Txt style={styles.metaLabel}>Due Date</Txt>
<Txt style={styles.metaValue}>{dateFmt(dueDate)}</Txt>
</View>
) : null}
</View>
</View>
<View style={styles.divider} />
<View style={styles.split}>
<View style={styles.half}>
<Txt style={styles.blockLabel}>Billed To</Txt>
<Txt style={styles.strong}>{clientName || "—"}</Txt>
{splitLines(clientAddress).map((line, i) => (
<Txt key={`client-${i}`}>{line}</Txt>
))}
{clientGstin ? <Txt>GSTIN: {clientGstin}</Txt> : null}
{placeOfSupplyStateCode ? <Txt>Place of Supply: {placeOfSupplyStateCode}</Txt> : null}
</View>
<View style={styles.half}>
{poNumber ? (
<>
<Txt style={styles.blockLabel}>PO Number</Txt>
<Txt style={styles.strong}>{poNumber}</Txt>
</>
) : null}
</View>
</View>
{/* Zero-height guard: keeps the header row from being stranded at the foot of a page. */}
<View minPresenceAhead={48} />
<View style={styles.table}>
<View style={[styles.tr, styles.trHead]} fixed>
<Txt style={[styles.th, styles.colDesc]}>Description</Txt>
<Txt style={[styles.th, styles.colRate]}>Unit Price</Txt>
<Txt style={[styles.th, styles.colQty]}>Qty</Txt>
<Txt style={[styles.th, styles.colTotal]}>Total</Txt>
</View>
{items.map((item, i) => (
<View style={styles.tr} key={item.id ?? i} wrap={false}>
<View style={styles.colDesc}>
{(splitLines(item.description).length ? splitLines(item.description) : ["—"]).map(
(line, li) => (
<Txt key={`desc-${li}`}>{line}</Txt>
),
)}
</View>
<Txt style={styles.colRate}>
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
</Txt>
<Txt style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Txt>
<Txt style={styles.colTotal}>{money(computeLineAmount(item))}</Txt>
</View>
))}
</View>
<View style={styles.totalsWrap} wrap={false}>
<View style={styles.totals}>
<View style={styles.totalsRow}>
<Txt>Subtotal</Txt>
<Txt>{money(subtotal)}</Txt>
</View>
{discount > 0 ? (
<View style={styles.totalsRow}>
<Txt>Discount</Txt>
<Txt>- {money(discount)}</Txt>
</View>
) : null}
{taxType === "cgst_sgst" ? (
<>
<View style={styles.totalsRow}>
<Txt>CGST ({(taxRate / 2).toFixed(2)}%)</Txt>
<Txt>{money(cgst)}</Txt>
</View>
<View style={styles.totalsRow}>
<Txt>SGST ({(taxRate / 2).toFixed(2)}%)</Txt>
<Txt>{money(sgst)}</Txt>
</View>
</>
) : null}
{taxType === "igst" ? (
<View style={styles.totalsRow}>
<Txt>IGST ({taxRate.toFixed(2)}%)</Txt>
<Txt>{money(igst)}</Txt>
</View>
) : null}
<View style={styles.grand}>
<Txt style={styles.grandText}>Total</Txt>
<Txt style={styles.grandText}>{money(total)}</Txt>
</View>
</View>
</View>
<Txt style={styles.words} wrap={false}>
Amount in words: {amountInWords}
</Txt>
{notes ? (
<View style={styles.notes} wrap={false}>
<Txt style={styles.blockLabel}>Notes</Txt>
{splitLines(notes).map((line, i) => (
<Txt key={`note-${i}`}>{line}</Txt>
))}
</View>
) : null}
<View style={styles.bottom} wrap={false}>
<View style={styles.bank}>
{bank ? (
<>
<Txt style={styles.blockLabel}>Pay To</Txt>
<Txt style={styles.strong}>{bank.accountName || vendor.vendorName}</Txt>
<Txt>{bank.bankName}</Txt>
{bank.accountNo ? <Txt>Account No.: {bank.accountNo}</Txt> : null}
{bank.branch ? <Txt>Branch: {bank.branch}</Txt> : null}
{bank.ifsc ? <Txt>IFSC: {bank.ifsc}</Txt> : null}
</>
) : null}
</View>
<View style={styles.signature}>
{signatureDataUri ? (
<>
<Image style={styles.signatureImg} src={signatureDataUri} />
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
</>
) : (
<>
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
<Txt style={styles.fineprint}>Computer-generated invoice; no signature is required.</Txt>
</>
)}
</View>
</View>
<View style={styles.footer} fixed>
<Text style={styles.footerLeft}>{vendor.vendorName}</Text>
<Text style={styles.footerCenter}>{"Generated with Voiced — a Bongbetic product"}</Text>
<Text
style={styles.footerRight}
render={({ pageNumber, totalPages }) =>
totalPages ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`
}
/>
</View>
</Page>
</Document>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { Page, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import type { PageFrame } from "./engine/geometry";
import { chromeHeight } from "./engine/metrics";
import type { RenderModel } from "./model/build";
import { AvailableWidthProvider } from "./blocks/AvailableWidth";
import { PageChrome } from "./blocks/PageChrome";
/** Height the footer strip takes inside the bottom padding: the text line, a 6 pt gap and the rule. */
export const FOOTER_RESERVE = 24;
/** Room above the content on pages 2+ for the continuation band, inside the top margin. */
export const CONTINUATION_RESERVE = 18;
const CHROME_INK = "#6f6f6f";
const RULE = "#e0e0e0";
const FOOTER_LEFT_WIDTH = 170;
export interface InvoicePageProps {
frame: PageFrame;
model: RenderModel;
children?: ReactNode;
}
/**
* The only `<Page>` in the app: size and padding come from the frame (no page-size or margin literal
* anywhere else), the flow is the template's, and the chrome (footer, continuation band) is added after
* it as fixed page-level boxes. No lineHeight here: it would reach the render-prop page-number text.
*/
export function InvoicePage({ frame, model, children }: InvoicePageProps) {
const { pad } = frame;
const marginBottom = pad.bottom - frame.footerReserve;
const ruleY = frame.page.height - marginBottom - chromeHeight("footer") - 6.6;
const bandLeft = `${model.docTitle} ${model.meta.number} · ${model.meta.date}`;
return (
<Page
size={frame.page.size}
style={{ paddingTop: pad.top, paddingRight: pad.right, paddingBottom: pad.bottom, paddingLeft: pad.left, color: "#161616" }}
>
<AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider>
<PageChrome
frame={frame}
footerLeft={model.supplier.name}
footerLeftWidth={FOOTER_LEFT_WIDTH}
footerCenter={model.footerText}
color={CHROME_INK}
band={{ left: bandLeft, right: "continued" }}
decor={[{ x: pad.left, y: ruleY, width: frame.contentWidth, height: 0.6, children: <View style={{ height: 0.6, backgroundColor: RULE }} /> }]}
/>
</Page>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Document } from "@react-pdf/renderer";
import type { PageFrame } from "./engine/geometry";
import type { RenderModel } from "./model/build";
import type { RenderPrefsV1 } from "./model/prefs";
import type { PlacedLogo, TemplateDefinition } from "./templates/contract";
import { InvoicePage } from "./InvoicePage";
export const PDF_PRODUCER = "Voiced / react-pdf 4.9.0";
export interface InvoicePdfDocProps {
model: RenderModel;
frame: PageFrame;
template: TemplateDefinition;
logo: PlacedLogo | null;
prefs: RenderPrefsV1;
/** Forwarded to the Document; renderCore reads the layout tree from it. */
onRender?: (props: unknown) => void;
}
/** The invoice date at 00:00 UTC. Fixed dates make the bytes of one invoice deterministic. */
function documentDate(iso: string): Date {
const d = new Date(`${iso}T00:00:00Z`);
return Number.isNaN(d.getTime()) ? new Date(0) : d;
}
export function InvoicePdf({ model, frame, template, logo, prefs, onRender }: InvoicePdfDocProps) {
const Layout = template.Layout;
const date = documentDate(model.meta.isoDate);
return (
<Document
title={model.pdfTitle}
author={model.supplier.name}
subject={`${model.docTitle} ${model.meta.number} for ${model.client.name}`}
keywords={`${model.meta.number}, ${model.client.name}`}
creator={`Voiced ${model.appVersion} (${template.id} v${template.version})`}
producer={PDF_PRODUCER}
language="en-IN"
creationDate={date}
modificationDate={date}
onRender={onRender as never}
>
<InvoicePage frame={frame} model={model}>
<Layout model={model} frame={frame} logo={logo} prefs={prefs} />
</InvoicePage>
</Document>
);
}
+39 -25
View File
@@ -10,6 +10,11 @@ import { pdfFlags } from "./pdfProps";
import { Cell, Row } from "./Row"; import { Cell, Row } from "./Row";
import { Txt } from "./Txt"; 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 { export interface TableRoles {
head: RoleName | TypeToken; head: RoleName | TypeToken;
body: RoleName | TypeToken; body: RoleName | TypeToken;
@@ -22,6 +27,8 @@ export interface TableLook {
padY?: number; padY?: number;
/** Bottom border of each row in pt (counted in the row height). Default 0. */ /** Bottom border of each row in pt (counted in the row height). Default 0. */
border?: number; 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>>; faceOverrides?: Readonly<Record<string, string>>;
} }
@@ -30,8 +37,8 @@ export interface ItemsTablePlanInput extends TableLook {
gap: number; gap: number;
/** Table width in pt (the content width of the page, or of the cell holding the table). */ /** Table width in pt (the content width of the page, or of the cell holding the table). */
width: number; width: number;
header: string[]; header: TableCellValue[];
rows: string[][]; rows: TableCellValue[][];
measurer: Measurer; measurer: Measurer;
roles: TableRoles; roles: TableRoles;
/** Height of whatever follows the last row and must stay with it (the totals). */ /** Height of whatever follows the last row and must stay with it (the totals). */
@@ -49,20 +56,23 @@ export interface ItemsTablePlan {
rowLines: number[]; 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); const t = roleToken(role);
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan"); 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"); let lines = 0;
const lines = Math.max( for (const text of cellParts(value)) {
1, if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
m.wrapLines(drawnText(t, text), inner, { lines += Math.max(
face: resolveFace(t.face, look.faceOverrides), 1,
weight: t.weight, m.wrapLines(drawnText(t, text), inner, {
italic: t.italic ?? false, face: resolveFace(t.face, look.faceOverrides),
size: t.size, weight: t.weight,
letterSpacing: (t.tracking ?? 0) * t.size, italic: t.italic ?? false,
}).length, size: t.size,
); letterSpacing: (t.tracking ?? 0) * t.size,
}).length,
);
}
return { lines, height: lines * t.leading }; 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"); 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 { widths } = colsDetailed(width, columns, gap);
const inner = widths.map((w) => Math.max(0, w - 2 * padX)); const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
const heightOf = (role: RoleName | TypeToken, cells: string[]) => { const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => {
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input)); 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 }; 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[]; columns: ColSpec[];
gap: number; gap: number;
width: number; width: number;
header: string[]; header: TableCellValue[];
rows: string[][]; rows: TableCellValue[][];
roles: TableRoles; roles: TableRoles;
/** Per column; default left. */ /** Per column; default left. */
align?: Array<"left" | "right" | "center">; align?: Array<"left" | "right" | "center">;
@@ -122,6 +132,8 @@ export interface TableRowsProps extends TableLook {
headerStyle?: Record<string, unknown>; headerStyle?: Record<string, unknown>;
rowStyle?: Record<string, unknown>; rowStyle?: Record<string, unknown>;
borderColor?: string; 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 * `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. * 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 padX = look.padX ?? 0;
const padY = look.padY ?? 4; const padY = look.padY ?? 4;
const border = look.border ?? 0; 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" } : {}), ...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
...extra, ...extra,
}); });
const cells = (role: RoleName | TypeToken, values: string[]) => const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) =>
values.map((v, i) => ( values.map((v, i) => (
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX }}> <Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
<Txt role={role} width={Math.max(0, widths[i] - 2 * padX)} align={align[i]} faceOverrides={look.faceOverrides}> {cellParts(v).map((part, li) => (
{v} <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}>
</Txt> {part}
</Txt>
))}
</Cell> </Cell>
)); ));
@@ -151,7 +165,7 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
<AvailableWidthProvider width={width}> <AvailableWidthProvider width={width}>
<View> <View>
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}> <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> </Row>
{rows.map((r, i) => ( {rows.map((r, i) => (
<Row <Row
+33 -2
View File
@@ -1,4 +1,5 @@
import { View } from "@react-pdf/renderer"; import { View } from "@react-pdf/renderer";
import { FILL } from "../engine/columns";
import { chromeHeight } from "../engine/metrics"; import { chromeHeight } from "../engine/metrics";
import { getMeasurer, measureToken } from "../engine/measure"; import { getMeasurer, measureToken } from "../engine/measure";
import type { PageFrame } from "../engine/geometry"; import type { PageFrame } from "../engine/geometry";
@@ -7,12 +8,21 @@ import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { PlanError } from "../engine/errors"; import { PlanError } from "../engine/errors";
import type { DecorItem } from "./FirstPageHeader"; import type { DecorItem } from "./FirstPageHeader";
import { pdfFlags } from "./pdfProps"; import { pdfFlags } from "./pdfProps";
import { FitText } from "./FitText";
import { RenderTxt } from "./RenderTxt"; import { RenderTxt } from "./RenderTxt";
export interface PageChromeProps { export interface PageChromeProps {
frame: PageFrame; frame: PageFrame;
/** Left footer text (e.g. the invoice number); omit for none. */ /** Left footer text (e.g. the invoice number); omit for none. */
footerLeft?: string; footerLeft?: string;
/**
* Pins the left text to this width (pt) and fits it to one line (shrunk, then cut with an ellipsis).
* A render-prop Text cannot be clamped, so a long supplier name there would wrap and push the footer
* up into the content. Without it the left text takes the remaining width.
*/
footerLeftWidth?: number;
/** Centre text between the left text and the page label. */
footerCenter?: string;
/** Show "Page n of N" at the right of the footer. Default true. */ /** Show "Page n of N" at the right of the footer. Default true. */
pageLabel?: boolean; pageLabel?: boolean;
/** Footer and band type; render-prop roles (no leading). Default "footer". */ /** Footer and band type; render-prop roles (no leading). Default "footer". */
@@ -26,6 +36,16 @@ export interface PageChromeProps {
decor?: DecorItem[]; decor?: DecorItem[];
} }
/**
* FitText is static text and needs a leading; the footer role has none (render-prop text), so the
* fitted left text borrows 1.4x the size, which stays within the auto-height line that chromeHeight
* reserves for the same role.
*/
function fineFooter(role: RoleName | TypeToken): TypeToken {
const t = roleToken(role);
return { ...t, leading: Math.round(t.size * 1.4 * 10) / 10 };
}
/** "Page 3 of 5"; before the total is known (first layout pass) just "Page 3". */ /** "Page 3 of 5"; before the total is known (first layout pass) just "Page 3". */
export function pageLabelText(pageNumber: number, totalPages: number | undefined): string { export function pageLabelText(pageNumber: number, totalPages: number | undefined): string {
return typeof totalPages === "number" && totalPages > 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`; return typeof totalPages === "number" && totalPages > 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`;
@@ -48,6 +68,8 @@ export function pageLabelText(pageNumber: number, totalPages: number | undefined
export function PageChrome({ export function PageChrome({
frame, frame,
footerLeft, footerLeft,
footerLeftWidth,
footerCenter,
pageLabel = true, pageLabel = true,
role = "footer", role = "footer",
color, color,
@@ -57,7 +79,7 @@ export function PageChrome({
}: PageChromeProps) { }: PageChromeProps) {
const { pad } = frame; const { pad } = frame;
const need = chromeHeight(role); const need = chromeHeight(role);
const showFooter = pageLabel || footerLeft !== undefined; const showFooter = pageLabel || footerLeft !== undefined || footerCenter !== undefined;
if (showFooter && frame.footerReserve < need) { if (showFooter && frame.footerReserve < need) {
throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve"); throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve");
} }
@@ -105,7 +127,16 @@ export function PageChrome({
style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }} style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
{...pdfFlags({ fixed: true })} {...pdfFlags({ fixed: true })}
> >
{footerLeft !== undefined && <RenderTxt role={role} color={color} grow render={() => footerLeft} />} {footerLeft !== undefined &&
(footerLeftWidth !== undefined ? (
<FitText role={fineFooter(role)} width={footerLeftWidth} color={color}>
{footerLeft}
</FitText>
) : (
<RenderTxt role={role} color={color} grow render={() => footerLeft} />
))}
{footerCenter !== undefined && <RenderTxt role={role} color={color} align="center" grow render={() => footerCenter} />}
{footerCenter === undefined && footerLeftWidth !== undefined && <View style={FILL as never} />}
{pageLabel && ( {pageLabel && (
<RenderTxt <RenderTxt
id={PAGE_LABEL_ID} id={PAGE_LABEL_ID}
+3 -3
View File
@@ -21,7 +21,7 @@ export interface RowProps extends PdfFlagsInput {
} }
/** /**
* A flex row whose children each sit in a cell of explicit width: fixed and fractional cells are pinned * A flex row, pinned to its total width (a row inside a flex-start parent would otherwise shrink to its content and leave its fill cell empty), whose children each sit in a cell of explicit width: fixed and fractional cells are pinned
* (width = minWidth = maxWidth) and the fill cell uses {flexGrow: 1, flexBasis: 0}. Descendants read the * (width = minWidth = maxWidth) and the fill cell uses {flexGrow: 1, flexBasis: 0}. Descendants read the
* cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is * cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is
* narrower than `stackBelow`), the children are stacked at full width instead. * narrower than `stackBelow`), the children are stacked at full width instead.
@@ -38,7 +38,7 @@ export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", wid
if (stacked) { if (stacked) {
return ( return (
<View style={{ flexDirection: "column", alignItems: "flex-start", ...style } as never} {...flags}> <View style={{ flexDirection: "column", alignItems: "flex-start", ...pinned(total), ...style } as never} {...flags}>
{kids.map((child, i) => ( {kids.map((child, i) => (
<View key={i} style={{ ...pinned(total), alignItems: "flex-start", ...(gap && i > 0 ? { marginTop: gap } : {}) } as never}> <View key={i} style={{ ...pinned(total), alignItems: "flex-start", ...(gap && i > 0 ? { marginTop: gap } : {}) } as never}>
<AvailableWidthProvider width={total}>{child}</AvailableWidthProvider> <AvailableWidthProvider width={total}>{child}</AvailableWidthProvider>
@@ -49,7 +49,7 @@ export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", wid
} }
return ( return (
<View style={{ flexDirection: "row", alignItems: align, ...style } as never} {...flags}> <View style={{ flexDirection: "row", alignItems: align, ...pinned(total), ...style } as never} {...flags}>
{kids.map((child, i) => { {kids.map((child, i) => {
const isFill = "fill" in widths[i]; const isFill = "fill" in widths[i];
const box = isFill ? FILL : pinned(w[i]); const box = isFill ? FILL : pinned(w[i]);
+41
View File
@@ -0,0 +1,41 @@
import { getMeasurer, measureToken } from "../engine/measure";
import { resolveFace, roleToken } from "../fonts/roles";
import { useAvailableWidth } from "./AvailableWidth";
import { IdText } from "./IdText";
import { Txt, type TxtProps } from "./Txt";
export interface SafeTxtProps extends Omit<TxtProps, "children" | "width" | "maxLines" | "grow"> {
children: string;
/** Width of the box; defaults to the available width of the enclosing cell. */
width?: number;
}
/**
* Free text (addresses, names, notes) that cannot overflow: normal text wraps at spaces in a box of the
* cell's width; a value with a single token wider than the cell (a long URL or an unbroken string) goes
* through IdText, which shrinks it or splits it at "@ . / - _". textkit never breaks inside a word,
* so without this such a token would run out of its box.
*/
export function SafeTxt({ children, width, align, ...rest }: SafeTxtProps) {
const avail = useAvailableWidth();
const w = width ?? avail;
const token = roleToken(rest.role);
const m = getMeasurer();
const family = resolveFace(token.face, rest.faceOverrides);
const widest = Math.max(
0,
...children.split(" ").map((word) => measureToken(m, token, word, { weight: rest.weight, italic: rest.italic, family })),
);
if (widest > w + 1e-6) {
return (
<IdText {...rest} width={w}>
{children}
</IdText>
);
}
return (
<Txt {...rest} align={align} width={w}>
{children}
</Txt>
);
}
+4 -2
View File
@@ -18,6 +18,8 @@ export interface TxtStyleProps {
} }
export interface TxtProps extends TxtStyleProps, PdfFlagsInput { export interface TxtProps extends TxtStyleProps, PdfFlagsInput {
/** react-pdf copies the id into the layout tree (audits and contract tests find nodes by it). */
id?: string;
role: RoleName | TypeToken; role: RoleName | TypeToken;
/** Clamp to this many lines and end with an ellipsis. */ /** Clamp to this many lines and end with an ellipsis. */
maxLines?: number; maxLines?: number;
@@ -56,7 +58,7 @@ export function assertNoLineHeight(style: Record<string, unknown> | undefined, w
* Static text. Always sets family, size, weight, style and an absolute `lineHeight: "Npt"` on the * Static text. Always sets family, size, weight, style and an absolute `lineHeight: "Npt"` on the
* same node; never put a lineHeight on an ancestor View. * same node; never put a lineHeight on an ancestor View.
*/ */
export function Txt({ role, color, align, weight, italic, maxLines, width, grow, style, faceOverrides, children, fixed, wrap, minPresenceAhead }: TxtProps) { export function Txt({ id, role, color, align, weight, italic, maxLines, width, grow, style, faceOverrides, children, fixed, wrap, minPresenceAhead }: TxtProps) {
const token = roleToken(role); const token = roleToken(role);
if (token.leading === undefined) throw new Error("Txt needs a role with a leading; use RenderTxt for render-prop text"); if (token.leading === undefined) throw new Error("Txt needs a role with a leading; use RenderTxt for render-prop text");
assertNoLineHeight(style, "Txt"); assertNoLineHeight(style, "Txt");
@@ -68,5 +70,5 @@ export function Txt({ role, color, align, weight, italic, maxLines, width, grow,
...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}), ...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}),
...style, ...style,
}; };
return <Text style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>; return <Text {...(id !== undefined ? { id } : {})} style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>;
} }
+32
View File
@@ -86,6 +86,23 @@ describe("PageChrome", () => {
expect(label.box.y + label.box.h).toBeLessThanOrEqual(300 - PAD + 0.01); expect(label.box.y + label.box.h).toBeLessThanOrEqual(300 - PAD + 0.01);
}); });
it("pins and fits a long left footer text to one line and centres the middle text", async () => {
const long = "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt";
const el = doc(
[...rows(8), h(PageChrome, { key: "chrome", frame, footerLeft: long, footerLeftWidth: 120, footerCenter: "Centre text" })],
{ pad: padding },
);
const r = await renderCore(el, { frame });
expect(r.issues).toEqual([]);
const texts = find(r.layout[0], (n) => n.type === "text" && n.fixed === true && n.box.y > 250);
const left = texts.find((n) => n.text?.startsWith("Venkatachalam"))!;
expect(left.lines).toHaveLength(1);
expect(left.lines![0].text.endsWith("…")).toBe(true);
expect(left.box.w).toBeCloseTo(120, 1);
const centre = texts.find((n) => n.lines?.some((l) => l.text === "Centre text"))!;
expect(centre.lines).toHaveLength(1);
});
it("rejects a footer reserve smaller than the line", () => { it("rejects a footer reserve smaller than the line", () => {
const tight = frameFor({ footerReserve: 4 }); const tight = frameFor({ footerReserve: 4 });
expect(() => PageChrome({ frame: tight })).toThrow(PlanError); expect(() => PageChrome({ frame: tight })).toThrow(PlanError);
@@ -130,6 +147,21 @@ describe("planItemsTable / TableRows", () => {
body.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1)); body.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1));
}); });
it("multi-line cells (one Txt per line) and per-column right padding match the laid-out heights", async () => {
const multi = [
[["First line", "Second line", "Third line that is long enough to wrap onto another line in a narrow column"], "1"],
["Single", "2"],
];
const padRight = [6, 0];
const p = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: multi, measurer: getMeasurer(), roles, padY: 4, border: 0.5, padRight });
expect(p.rowLines[0]).toBeGreaterThanOrEqual(4);
const el = doc(
h(AvailableWidthProvider, { width: WIDTH }, h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: multi, roles, padY: 4, border: 0.5, padRight })),
);
const page = (await renderCore(el)).layout[0];
multi.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1));
});
it("throws a typed PlanError when the totals tail can never share a page with the header and last row", () => { it("throws a typed PlanError when the totals tail can never share a page with the header and last row", () => {
const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 }; const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 };
const p = plan(); const p = plan();
+157
View File
@@ -0,0 +1,157 @@
import { Image, View } from "@react-pdf/renderer";
import { pinned } from "../engine/columns";
import { textLines } from "../engine/textplan";
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import type { RenderModel } from "../model/build";
import { useAvailableWidth } from "./AvailableWidth";
import { IdText } from "./IdText";
import { Row } from "./Row";
import { SafeTxt } from "./SafeTxt";
import { Txt } from "./Txt";
/*
* Regulated data (GSTIN, place of supply, HSN/SAC, tax rows, amount in words, signatory, reverse
* charge) is printed ONLY through these components, so one contract test can prove that every
* template carries what a document type requires (model/slots.ts) and nothing it forbids. Each slot
* stamps an id (`slot-*`) on its output so the test finds it in the layout tree. Templates must not
* read these model fields themselves (slots.lint.test.ts).
*/
export const SLOT_ID = {
gstin: "slot-gstin",
pos: "slot-pos",
taxRows: "slot-tax-rows",
amountInWords: "slot-words",
signatory: "slot-signatory",
reverseCharge: "slot-reverse-charge",
} as const;
type Role = RoleName | TypeToken;
const INK_SUB = "#393939";
const FINE = "#6f6f6f";
const SIGNATURE_RULE = "#8d8d8d";
function Gstin({ model, party = "supplier", role = "body", color }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string }) {
const value = party === "supplier" ? (model.slots.gstin ? model.supplier.gstin : null) : model.client.gstin;
if (!value) return null;
return (
<View id={`${SLOT_ID.gstin}-${party}`}>
<IdText role={role} color={color}>{`GSTIN: ${value}`}</IdText>
</View>
);
}
function PlaceOfSupply({ model, role = "body", color }: { model: RenderModel; role?: Role; color?: string }) {
if (!model.client.posLabel) return null;
return (
<SafeTxt id={SLOT_ID.pos} role={role} color={color}>
{`Place of Supply: ${model.client.posLabel}`}
</SafeTxt>
);
}
/** The HSN/SAC column: header and one value per item row, or null when no item carries a code. */
function hsnSacColumn(model: RenderModel): { header: string; values: string[] } | null {
if (!model.slots.hsn) return null;
return { header: "HSN/SAC", values: model.items.map((i) => i.hsnSac ?? "—") };
}
export interface TaxRowsProps {
model: RenderModel;
role?: Role;
amountWidth: number;
rowStyle?: Record<string, unknown>;
}
/** CGST/SGST (UTGST) or IGST rows, as label (rate) on the left and the amount on the right. */
function TaxRows({ model, role = "body", amountWidth, rowStyle }: TaxRowsProps) {
const w = useAvailableWidth();
if (model.totals.taxRows.length === 0) return null;
return (
<View id={SLOT_ID.taxRows}>
{model.totals.taxRows.map((r, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} width={w} style={rowStyle}>
<Txt role={role} width={w - amountWidth}>{r.rateText ? `${r.label} (${r.rateText})` : r.label}</Txt>
<Txt role={role} width={amountWidth} align="right">{r.amountText}</Txt>
</Row>
))}
</View>
);
}
function AmountInWords({ model, role = "body", style }: { model: RenderModel; role?: Role; style?: Record<string, unknown> }) {
if (!model.amountInWords) return null;
return (
<SafeTxt id={SLOT_ID.amountInWords} role={role} italic style={style}>
{`Amount in words: ${model.amountInWords}`}
</SafeTxt>
);
}
function ReverseCharge({ model, role = "body", color, style }: { model: RenderModel; role?: Role; color?: string; style?: Record<string, unknown> }) {
if (!model.reverseCharge) return null;
return (
<SafeTxt id={SLOT_ID.reverseCharge} role={role} color={color} style={style}>
{model.reverseCharge}
</SafeTxt>
);
}
/** Predicted heights of the slots that sit in the totals block, for its presence value. */
export const slotHeights = {
/** Number of tax rows (each is one body line plus the template's row padding). */
taxRowCount: (model: RenderModel): number => model.totals.taxRows.length,
amountInWords: (model: RenderModel, width: number): number =>
model.amountInWords ? textLines("body", `Amount in words: ${model.amountInWords}`, width, { italic: true }) * (roleToken("body").leading as number) : 0,
reverseCharge: (model: RenderModel, width: number): number =>
model.reverseCharge ? textLines("small", model.reverseCharge, width) * (roleToken("small").leading as number) : 0,
};
export const SIGNATURE_IMAGE_H = 58;
const FINE_LINE = "Computer-generated invoice; no signature is required.";
/** Height of the signatory block at `width`, for the closing block's presence value. */
export function signatoryHeight(model: RenderModel, width: number): number {
const leading = (r: Role) => roleToken(r).leading as number;
const { name, designation, dataUri } = model.signature;
let h = (dataUri ? SIGNATURE_IMAGE_H : 0) + 4 + 0.8 + 3;
h += textLines("bodyStrong", name, width) * leading("bodyStrong");
if (designation) h += textLines("body", designation, width) * leading("body");
if (!dataUri) h += 6 + textLines("fine", FINE_LINE, width) * leading("fine");
return h;
}
/**
* Signature image (if any), a rule, the signatory's name and designation. Without an image the
* "computer-generated" line stands in for the signature.
*/
function Signatory({ model, width }: { model: RenderModel; width?: number }) {
const avail = useAvailableWidth();
const w = width ?? avail;
const { name, designation, dataUri } = model.signature;
return (
<View id={SLOT_ID.signatory} style={pinned(w) as never}>
{dataUri ? (
// Pinned box (an Image may be pinned); the picture sits at its right and bottom edge, just above the rule.
<Image
src={dataUri}
style={{ ...pinned(w), height: SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: "100%", objectPositionY: "100%" } as never}
/>
) : null}
<View style={{ marginTop: 4, paddingTop: 3, borderTopWidth: 0.8, borderTopColor: SIGNATURE_RULE, ...pinned(w) } as never}>
<SafeTxt role="bodyStrong" width={w} align="right">{name}</SafeTxt>
{designation ? (
<SafeTxt role="body" width={w} align="right" color={INK_SUB}>{designation}</SafeTxt>
) : null}
</View>
{!dataUri ? (
<Txt role="fine" color={FINE} width={w} align="right" style={{ marginTop: 6 }}>
{FINE_LINE}
</Txt>
) : null}
</View>
);
}
export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge };
+6
View File
@@ -17,3 +17,9 @@ export const TABLE_HEAD_ID = "tbl-head";
/** Body rows are `tbl-row-<index>`: unique, so a row on two pages is visible as a split. */ /** Body rows are `tbl-row-<index>`: unique, so a row on two pages is visible as a split. */
export const TABLE_ROW_PREFIX = "tbl-row-"; export const TABLE_ROW_PREFIX = "tbl-row-";
/** The closing block (bank + signatory): the audit expects its bottom on the wrap-area bottom of the last page. */
export const CLOSING_ID = "closing";
/** The totals block (subtotal ... amount in words): never split across pages. */
export const TOTALS_ID = "totals";
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { computeLogoPlacement } from "./logo";
const BOX = { w: 170, h: 54 };
const base = { box: BOX, availableWidth: 299, minTextWidth: 160 };
describe("computeLogoPlacement", () => {
it("the Test Vendor trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
const p = computeLogoPlacement({ ...base, aspect: 4.6 });
expect(p.w).toBeCloseTo(170, 6);
expect(p.h).toBeCloseTo(170 / 4.6, 6);
expect(p.stacked).toBe(true);
});
it("an untrimmed 3:1 logo is height-limited (54 pt) and still stacks", () => {
const p = computeLogoPlacement({ ...base, aspect: 3 });
expect(p.h).toBe(54);
expect(p.w).toBeCloseTo(162, 6);
expect(p.stacked).toBe(true);
});
it("a square logo is 54 x 54 and sits beside the text", () => {
const p = computeLogoPlacement({ ...base, aspect: 1 });
expect(p).toEqual({ w: 54, h: 54, stacked: false });
});
it("stacks exactly when the text beside the logo would drop under the minimum", () => {
// 299 - 54 - 14 = 231 beside a square; a 2:1 logo (108 wide) leaves 177; a 2.5:1 one (135) leaves 150.
expect(computeLogoPlacement({ ...base, aspect: 2 }).stacked).toBe(false);
expect(computeLogoPlacement({ ...base, aspect: 2.5 }).stacked).toBe(true);
// The boundary: leaves exactly 160.
const w = 299 - 14 - 160;
expect(computeLogoPlacement({ ...base, aspect: w / 54 }).stacked).toBe(false);
expect(computeLogoPlacement({ ...base, aspect: (w + 0.01) / 54 }).stacked).toBe(true);
});
it("gives the logo more room on a wider column (Letter)", () => {
const a4 = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 299.28 });
const letter = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 316 });
expect(a4.stacked).toBe(true);
expect(letter.stacked).toBe(false);
});
it("never exceeds the box, whatever the aspect", () => {
for (const aspect of [0.2, 0.5, 1, 1.5, 3, 4.6, 10, 40]) {
const p = computeLogoPlacement({ ...base, aspect });
expect(p.w).toBeLessThanOrEqual(170 + 1e-9);
expect(p.h).toBeLessThanOrEqual(54 + 1e-9);
expect(p.w / p.h).toBeCloseTo(aspect, 6);
}
});
it("falls back to the box aspect for a bad aspect", () => {
const p = computeLogoPlacement({ ...base, aspect: Number.NaN });
expect(p.w).toBeCloseTo(170, 6);
expect(p.h).toBeCloseTo(54, 6);
});
});
+38
View File
@@ -0,0 +1,38 @@
export interface LogoPlacementInput {
/** Width / height of the image (use the trimmed ink aspect once the logo pipeline provides it). */
aspect: number;
/** The largest box the logo may fill, pt. */
box: { w: number; h: number };
/** Width of the column the logo and the text beside it share. */
availableWidth: number;
/** Narrowest the text may get beside the logo before the logo moves above it. */
minTextWidth: number;
/** Space between logo and text when side by side. Default 14. */
gap?: number;
}
export interface LogoPlacement {
w: number;
h: number;
/** True when the logo goes above the text instead of beside it. */
stacked: boolean;
}
export const LOGO_GAP = 14;
/**
* Fits the logo inside `box` by its aspect (contain), then decides whether it still leaves the text
* `minTextWidth` beside it. Same maths as the Phase 0 planLogo.
*/
export function computeLogoPlacement({ aspect, box, availableWidth, minTextWidth, gap = LOGO_GAP }: LogoPlacementInput): LogoPlacement {
const a = Number.isFinite(aspect) && aspect > 0 ? aspect : box.w / box.h;
const h = Math.min(box.h, box.w / a);
const w = h * a;
return { w, h, stacked: availableWidth - w - gap < minTextWidth };
}
/*
* Seam for reference templates: slots.generated.ts (measured placeholder boxes, see
* scripts/logo/measure-placeholders.mjs) will supply a template's `logo.box` and may refine the fit.
* Until it exists, every template declares its box by hand in its TemplateDefinition.
*/
+30
View File
@@ -0,0 +1,30 @@
import { drawnText, getMeasurer } from "./measure";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
export interface TextPlanOpts {
weight?: number;
italic?: boolean;
faceOverrides?: Readonly<Record<string, string>>;
}
/** Predicted line count of `text` in a box `width` pt wide (greedy estimate; see Measurer.wrapLines). */
export function textLines(role: RoleName | TypeToken, text: string, width: number, opts: TextPlanOpts = {}): number {
const t = roleToken(role);
return Math.max(
1,
getMeasurer().wrapLines(drawnText(t, text), Math.max(1, width), {
face: resolveFace(t.face, opts.faceOverrides),
weight: opts.weight ?? t.weight,
italic: opts.italic ?? t.italic ?? false,
size: t.size,
letterSpacing: (t.tracking ?? 0) * t.size,
}).length,
);
}
/** Predicted height in pt of one static Txt: lines times the role's leading. */
export function textHeight(role: RoleName | TypeToken, text: string, width: number, opts: TextPlanOpts = {}): number {
const t = roleToken(role);
if (t.leading === undefined) throw new Error("textHeight needs a role with a leading");
return textLines(role, text, width, opts) * t.leading;
}
-11
View File
@@ -1,11 +0,0 @@
import { registerBundledFonts } from "./fonts/register";
/**
* Registers the fonts used by the invoice PDF. Safe to call more than once.
*
* `base` is a URL prefix inside the app ("/fonts") and a directory path when rendering under Node
* (tests); the families live in per-family folders below it (see public/fonts/manifest.json).
*/
export function registerPdfFonts(base = "/fonts"): void {
registerBundledFonts(base);
}
+1 -4
View File
@@ -6,15 +6,12 @@ import { formatViolation, lintSource } from "./testing/layoutLint";
const ROOT = process.cwd(); const ROOT = process.cwd();
const PDF_DIR = path.join(ROOT, "src/pdf"); const PDF_DIR = path.join(ROOT, "src/pdf");
// TODO: remove this exclusion when the legacy InvoiceDocument.tsx is deleted (it predates the primitives).
const EXCLUDED = new Set(["src/pdf/InvoiceDocument.tsx"]);
function sources(dir: string): string[] { function sources(dir: string): string[] {
return readdirSync(dir).flatMap((name) => { return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name); const full = path.join(dir, name);
if (statSync(full).isDirectory()) return sources(full); if (statSync(full).isDirectory()) return sources(full);
const rel = path.relative(ROOT, full).split(path.sep).join("/"); const rel = path.relative(ROOT, full).split(path.sep).join("/");
if (!/\.(ts|tsx)$/.test(name) || /\.test\.|\.d\.ts$|\.generated\./.test(name) || EXCLUDED.has(rel)) return []; if (!/\.(ts|tsx)$/.test(name) || /\.test\.|\.d\.ts$|\.generated\./.test(name)) return [];
return [rel]; return [rel];
}); });
} }
+12 -4
View File
@@ -97,7 +97,7 @@ describe("unregistered invoice", () => {
expect(m.docTitle).toBe("Invoice"); expect(m.docTitle).toBe("Invoice");
expect(m.docType).toBe("invoice"); expect(m.docType).toBe("invoice");
expect(m.pdfTitle).toBe("Invoice INV-0001"); expect(m.pdfTitle).toBe("Invoice INV-0001");
expect(m.slots).toEqual({ gstin: false, clientGstin: false, pos: false, hsn: true, taxRows: false }); expect(m.slots).toEqual({ gstin: false, clientGstin: false, pos: false, hsn: true, taxRows: false, reverseCharge: false });
expect(m.totals).toMatchObject({ expect(m.totals).toMatchObject({
subtotalText: "₹5,500.50", subtotalText: "₹5,500.50",
discountText: null, discountText: null,
@@ -119,7 +119,7 @@ describe("unregistered invoice", () => {
expect(m.client.addressLines).toEqual(["1 Main Road", "Mumbai"]); expect(m.client.addressLines).toEqual(["1 Main Road", "Mumbai"]);
expect(m.client.poNumber).toBeNull(); expect(m.client.poNumber).toBeNull();
expect(m.notesLines).toEqual(["Thanks", "Pay in 30 days"]); expect(m.notesLines).toEqual(["Thanks", "Pay in 30 days"]);
expect(m.meta).toEqual({ numberLabel: "Invoice No.", number: "INV-0001", date: "15 Sept 2026", dueDate: "05 Oct 2026" }); expect(m.meta).toEqual({ numberLabel: "Invoice No.", number: "INV-0001", date: "15 Sept 2026", isoDate: "2026-09-15", dueDate: "05 Oct 2026" });
}); });
it("builds items with rate, quantity and em-dash fallback", () => { it("builds items with rate, quantity and em-dash fallback", () => {
expect(m.items[0]).toEqual({ expect(m.items[0]).toEqual({
@@ -179,16 +179,24 @@ describe("CGST + SGST tax invoice", () => {
expect(m.totals.discountText).toBe("₹500.50"); expect(m.totals.discountText).toBe("₹500.50");
expect(m.totals.taxableText).toBe("₹5,000.00"); expect(m.totals.taxableText).toBe("₹5,000.00");
expect(m.totals.totalText).toBe("₹5,250.00"); expect(m.totals.totalText).toBe("₹5,250.00");
expect(m.slots).toEqual({ gstin: true, clientGstin: true, pos: true, hsn: true, taxRows: true }); expect(m.slots).toEqual({ gstin: true, clientGstin: true, pos: true, hsn: true, taxRows: true, reverseCharge: true });
expect(m.client.posLabel).toBe("29 – Karnataka"); expect(m.client.posLabel).toBe("29 – Karnataka");
expect(m.client.gstin).toBe("29ABCDE1234F1Z5"); expect(m.client.gstin).toBe("29ABCDE1234F1Z5");
expect(m.client.poNumber).toBe("PO-77"); expect(m.client.poNumber).toBe("PO-77");
expect(m.supplier.showGstin).toBe(true); expect(m.supplier.showGstin).toBe(true);
expect(m.reverseCharge).toMatch(/reverse charge/i); expect(m.reverseCharge).toBe("Tax is payable on reverse charge: Yes");
expect(m.signature).toMatchObject({ name: "A. Person", designation: "Proprietor" }); expect(m.signature).toMatchObject({ name: "A. Person", designation: "Proprietor" });
}); });
}); });
describe("reverse charge line", () => {
it("is always present on a tax invoice (Yes or No) and absent on a plain invoice", () => {
expect(make({ docType: "tax_invoice", reverseCharge: false }).reverseCharge).toBe("Tax is payable on reverse charge: No");
expect(make({ docType: "tax_invoice", reverseCharge: true }).reverseCharge).toBe("Tax is payable on reverse charge: Yes");
expect(make({ docType: "invoice", reverseCharge: true }).reverseCharge).toBeNull();
});
});
describe("IGST", () => { describe("IGST", () => {
const m = make({ docType: "tax_invoice", taxType: "igst", taxRate: 18, igstAmount: 990.09, total: 6490.59, placeOfSupplyStateCode: "27" }); const m = make({ docType: "tax_invoice", taxType: "igst", taxRate: 18, igstAmount: 990.09, total: 6490.59, placeOfSupplyStateCode: "27" });
it("has one IGST row at the full rate", () => { it("has one IGST row at the full rate", () => {
+6 -2
View File
@@ -58,7 +58,7 @@ export interface RenderModel {
stateCode: string; stateCode: string;
showGstin: boolean; showGstin: boolean;
}; };
meta: { numberLabel: string; number: string; date: string; dueDate: string | null }; meta: { numberLabel: string; number: string; date: string; /** yyyy-mm-dd, for the PDF metadata dates. */ isoDate: string; dueDate: string | null };
client: { client: {
name: string; name: string;
addressLines: string[]; addressLines: string[];
@@ -83,6 +83,7 @@ export interface RenderModel {
logo: LogoInput | null; logo: LogoInput | null;
footerText: string; footerText: string;
appVersion: string; appVersion: string;
/** "Tax is payable on reverse charge: Yes/No" on a tax invoice; null on a plain invoice. */
reverseCharge: string | null; reverseCharge: string | null;
cancelled: boolean; cancelled: boolean;
/** Which regulated slots have content, for the template contract tests. */ /** Which regulated slots have content, for the template contract tests. */
@@ -92,6 +93,7 @@ export interface RenderModel {
pos: boolean; pos: boolean;
hsn: boolean; hsn: boolean;
taxRows: boolean; taxRows: boolean;
reverseCharge: boolean;
}; };
} }
@@ -195,6 +197,7 @@ export function buildRenderModel(input: BuildInput): RenderModel {
numberLabel: "Invoice No.", numberLabel: "Invoice No.",
number: sanitize(invoice.number), number: sanitize(invoice.number),
date: formatDate(invoice.invoiceDate), date: formatDate(invoice.invoiceDate),
isoDate: invoice.invoiceDate,
dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : null, dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : null,
}, },
client: { client: {
@@ -223,7 +226,7 @@ export function buildRenderModel(input: BuildInput): RenderModel {
logo, logo,
footerText: "Generated with Voiced — a Bongbetic product", footerText: "Generated with Voiced — a Bongbetic product",
appVersion, appVersion,
reverseCharge: invoice.reverseCharge ? "Tax is payable on reverse charge: Yes" : null, reverseCharge: docType === "tax_invoice" ? `Tax is payable on reverse charge: ${invoice.reverseCharge ? "Yes" : "No"}` : null,
cancelled: invoice.status === "cancelled", cancelled: invoice.status === "cancelled",
slots: { slots: {
gstin: showGstin, gstin: showGstin,
@@ -231,6 +234,7 @@ export function buildRenderModel(input: BuildInput): RenderModel {
pos: posLabel !== null, pos: posLabel !== null,
hsn, hsn,
taxRows: taxRows.length > 0, taxRows: taxRows.length > 0,
reverseCharge: docType === "tax_invoice",
}, },
}; };
} }
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PREFS, parsePrefs, prefsToJson } from "./prefs";
describe("render prefs", () => {
it("defaults to Classic on A4 with the template margins", () => {
expect(DEFAULT_PREFS).toEqual({ version: 1, templateId: "classic", pageSize: "A4", margins: "template" });
});
it("round-trips through JSON", () => {
const p = { version: 1 as const, templateId: "classic", pageSize: "LETTER" as const, margins: "narrow" as const, logo: { useWordmark: true } };
expect(parsePrefs(prefsToJson(p))).toEqual(p);
});
it("is tolerant: empty, malformed and out-of-range input fall back field by field", () => {
expect(parsePrefs(undefined)).toEqual(DEFAULT_PREFS);
expect(parsePrefs("")).toEqual(DEFAULT_PREFS);
expect(parsePrefs("{not json")).toEqual(DEFAULT_PREFS);
expect(parsePrefs("42")).toEqual(DEFAULT_PREFS);
expect(parsePrefs("null")).toEqual(DEFAULT_PREFS);
expect(parsePrefs(JSON.stringify({ pageSize: "LETTER", margins: "wide", templateId: 7, logo: { useWordmark: "yes" } }))).toEqual({
...DEFAULT_PREFS,
pageSize: "LETTER",
});
});
it("does not hand out the shared default object", () => {
expect(parsePrefs(null)).not.toBe(DEFAULT_PREFS);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { MarginPreset, PageId } from "../engine/geometry";
/** Page and template choices frozen with an invoice at issue (stored as JSON). */
export interface RenderPrefsV1 {
version: 1;
templateId: string;
pageSize: PageId;
margins: MarginPreset;
logo?: {
/** The logo already contains the business name, so a template drops its own name lockup. */
useWordmark?: boolean;
};
}
export const DEFAULT_PREFS: RenderPrefsV1 = {
version: 1,
templateId: "classic",
pageSize: "A4",
margins: "template",
};
const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"];
const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"];
/** Tolerant: anything unreadable or out of range falls back to the default for that field. */
export function parsePrefs(json: string | null | undefined): RenderPrefsV1 {
if (!json) return { ...DEFAULT_PREFS };
let raw: unknown;
try {
raw = JSON.parse(json);
} catch {
return { ...DEFAULT_PREFS };
}
if (typeof raw !== "object" || raw === null) return { ...DEFAULT_PREFS };
const o = raw as Record<string, unknown>;
const prefs: RenderPrefsV1 = {
version: 1,
templateId: typeof o.templateId === "string" && o.templateId ? o.templateId : DEFAULT_PREFS.templateId,
pageSize: PAGE_SIZE_IDS.includes(o.pageSize as PageId) ? (o.pageSize as PageId) : DEFAULT_PREFS.pageSize,
margins: MARGIN_IDS.includes(o.margins as MarginPreset) ? (o.margins as MarginPreset) : DEFAULT_PREFS.margins,
};
const logo = o.logo;
if (typeof logo === "object" && logo !== null && typeof (logo as { useWordmark?: unknown }).useWordmark === "boolean") {
prefs.logo = { useWordmark: (logo as { useWordmark: boolean }).useWordmark };
}
return prefs;
}
export const prefsToJson = (prefs: RenderPrefsV1): string => JSON.stringify(prefs);
+63
View File
@@ -0,0 +1,63 @@
import type { RenderModel } from "./build";
/** Regulated data that templates may print only through `Slot.*` (blocks/slots.tsx). */
export type SlotName = "gstin" | "pos" | "hsn" | "taxRows" | "amountInWords" | "signatory" | "reverseCharge";
export interface SlotRule {
/** Must appear in the rendered text whenever the model carries its content. */
required: SlotName[];
/** Must not appear at all. */
forbidden: SlotName[];
}
/**
* What each document type must and must not print. A tax invoice carries the full GST set; a plain
* invoice from an unregistered supplier has no GSTIN row, no tax rows and no reverse-charge line.
*/
export const SLOT_RULES: Record<RenderModel["docType"], SlotRule> = {
tax_invoice: {
required: ["gstin", "pos", "hsn", "taxRows", "amountInWords", "signatory", "reverseCharge"],
forbidden: [],
},
invoice: {
required: ["amountInWords", "signatory"],
forbidden: ["gstin", "taxRows", "reverseCharge"],
},
};
/**
* The strings a slot is expected to put on the page, from the model. Empty when the slot has no
* content in this model. Used by the contract tests, so keep it in step with blocks/slots.tsx.
*/
export function slotContent(model: RenderModel, slot: SlotName): string[] {
switch (slot) {
case "gstin":
return model.slots.gstin && model.supplier.gstin ? [`GSTIN: ${model.supplier.gstin}`] : [];
case "pos":
return model.client.posLabel ? [`Place of Supply: ${model.client.posLabel}`] : [];
case "hsn":
return model.slots.hsn ? ["HSN/SAC", ...model.items.flatMap((i) => (i.hsnSac ? [i.hsnSac] : []))] : [];
case "taxRows":
return model.totals.taxRows.flatMap((r) => [r.label, ...(r.rateText ? [r.rateText] : []), r.amountText]);
case "amountInWords":
return model.amountInWords ? [`Amount in words: ${model.amountInWords}`] : [];
case "signatory":
return [model.signature.name, ...(model.signature.designation ? [model.signature.designation] : [])];
case "reverseCharge":
return model.reverseCharge ? [model.reverseCharge] : [];
}
}
/** Text that proves a forbidden slot leaked, independent of the model (the model may hold stale data). */
export function forbiddenProbe(model: RenderModel, slot: SlotName, rawSupplierGstin?: string): string[] {
switch (slot) {
case "gstin":
return ["GSTIN: " + (rawSupplierGstin ?? model.supplier.gstin ?? "")].filter((s) => s !== "GSTIN: ");
case "taxRows":
return ["CGST", "SGST", "UTGST", "IGST"];
case "reverseCharge":
return ["reverse charge"];
default:
return [];
}
}
+64
View File
@@ -0,0 +1,64 @@
import { createMeasurer, setMeasurer } from "../engine/measure";
import { FONT_MANIFEST } from "../fonts/manifest";
import { preflightFonts, registerBundledFonts } from "../fonts/register";
export interface BrowserInitOptions {
/** URL prefix of the bundled fonts. Default "/fonts". */
base?: string;
/** Fetches a URL as bytes; defaults to `fetch`. Tests and the worker can supply their own. */
fetchBytes?: (url: string) => Promise<Uint8Array>;
}
async function defaultFetchBytes(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
let ready: Promise<void> | null = null;
/**
* Prepares rendering where the app runs it (the webview now, the render worker later): fetches every
* bundled font once, checks each by its magic bytes (Tauri answers an unknown path with index.html and
* HTTP 200, so the status proves nothing), registers the families with react-pdf and installs the
* fontkit measurer on the same bytes. Safe to call repeatedly; a failure is reported once and a later
* call tries again.
*/
export function initBrowserRendering(opts: BrowserInitOptions = {}): Promise<void> {
if (!ready) {
ready = run(opts).catch((err) => {
ready = null;
throw err;
});
}
return ready;
}
async function run({ base = "/fonts", fetchBytes = defaultFetchBytes }: BrowserInitOptions): Promise<void> {
const bytesByUrl = new Map<string, Uint8Array>();
const problems = await preflightFonts(async (url) => {
const bytes = await fetchBytes(url);
bytesByUrl.set(url, bytes);
return bytes;
}, base);
if (problems.length > 0) {
const shown = problems.slice(0, 3).join("; ");
const more = problems.length > 3 ? ` (and ${problems.length - 3} more)` : "";
throw new Error(`The PDF fonts could not be loaded, so the invoice cannot be rendered: ${shown}${more}. Reinstall the app if this persists.`);
}
registerBundledFonts(base);
const known = new Set(FONT_MANIFEST.families.flatMap((f) => f.fonts.map((font) => `${f.folder}/${font.file}`)));
setMeasurer(
createMeasurer((file) => {
const bytes = known.has(file) ? bytesByUrl.get(`${base}/${file}`) : undefined;
if (!bytes) throw new Error(`font ${file} was not preloaded`);
return bytes;
}),
);
}
/** For tests: forget a previous initialisation. */
export function resetBrowserRendering(): void {
ready = null;
}
+63
View File
@@ -0,0 +1,63 @@
import { createElement } from "react";
import { computeLogoPlacement } from "../engine/logo";
import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build";
import type { RenderPrefsV1 } from "../model/prefs";
import { CLOSING_ID } from "../engine/ids";
import { marginsFor, type PlacedLogo } from "../templates/contract";
import { getTemplate } from "../templates/registry";
import { CONTINUATION_RESERVE, FOOTER_RESERVE } from "../InvoicePage";
import { InvoicePdf } from "../InvoicePdf";
import { renderCore, type RenderBytes, type RenderCoreResult } from "./core";
export interface RenderInvoiceInput {
model: RenderModel;
prefs: RenderPrefsV1;
}
export interface RenderInvoiceResult {
bytes: Uint8Array;
pages: number;
issues: AuditIssue[];
fingerprint: string;
/** The raw core result, for tests that read the layout tree. */
core: RenderCoreResult;
}
/** The page frame for a set of prefs: template margins under the chosen preset, page size, chrome reserves. */
export function frameForPrefs(prefs: RenderPrefsV1): PageFrame {
const template = getTemplate(prefs.templateId);
return computeFrame({
page: PAGE_SIZES[prefs.pageSize],
margins: marginsFor(template, prefs.margins),
footerReserve: FOOTER_RESERVE,
continuationReserve: CONTINUATION_RESERVE,
letterMode: true,
});
}
/**
* model + prefs -> template, frame, logo placement and keep-outs -> InvoicePdf -> renderCore. Needs
* fonts registered and a measurer installed (render/browserInit.ts in the app, testing/setup.ts in tests).
*/
export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
const template = getTemplate(prefs.templateId);
const frame = frameForPrefs(prefs);
let logo: PlacedLogo | null = null;
if (model.logo && template.logo.defaultOn) {
const placed = computeLogoPlacement({
aspect: model.logo.width / model.logo.height,
box: template.logo.box,
availableWidth: template.logo.slotWidth(frame),
minTextWidth: template.logo.stackBelowTextWidth,
});
logo = { dataUri: model.logo.dataUri, ...placed };
}
// No decor yet, so no keep-outs; templates with corner art will return theirs here.
const element = createElement(InvoicePdf, { model, frame, template, logo, prefs });
const core = await renderCore(element, { frame, decorBoxes: [], audit: { closingId: CLOSING_ID } }, bytesOf);
return { bytes: core.bytes, pages: core.pages, issues: core.issues, fingerprint: core.fingerprint, core };
}
+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([]);
});
});
+7 -3
View File
@@ -28,11 +28,15 @@ export interface RenderedPdf {
export async function renderPdfText(element: ReactElement): Promise<RenderedPdf> { export async function renderPdfText(element: ReactElement): Promise<RenderedPdf> {
// The renderToBuffer type wants a <Document> element; any element that renders one works. // The renderToBuffer type wants a <Document> element; any element that renders one works.
const buf = await renderToBuffer(element as Parameters<typeof renderToBuffer>[0]); const buf = await renderToBuffer(element as Parameters<typeof renderToBuffer>[0]);
const raw = buf.toString("latin1"); return parsePdfText(new Uint8Array(buf));
const bytes = new Uint8Array(buf); }
/** The text boxes pdf.js finds on each page of already-rendered PDF bytes. */
export async function parsePdfText(bytes: Uint8Array): Promise<RenderedPdf> {
const raw = Buffer.from(bytes).toString("latin1");
const task = getDocument({ const task = getDocument({
data: new Uint8Array(buf), data: new Uint8Array(bytes),
useSystemFonts: false, useSystemFonts: false,
disableFontFace: true, disableFontFace: true,
}); });
+18
View File
@@ -38,3 +38,21 @@ export async function inkPixels(
await task.destroy(); await task.destroy();
} }
} }
/** PNG of one page (1-based) at `scale`, white background; for visual review only. */
export async function pagePng(canvasLib: typeof import("@napi-rs/canvas"), bytes: Uint8Array, pageNo: number, scale = 1.5): Promise<Buffer> {
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
const doc = await task.promise;
try {
const page = await doc.getPage(pageNo);
const viewport = page.getViewport({ scale });
const canvas = canvasLib.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx as never, canvas: canvas as never, viewport }).promise;
return canvas.toBuffer("image/png");
} finally {
await task.destroy();
}
}
+3 -4
View File
@@ -51,12 +51,12 @@ import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords"; import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators"; import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format"; import { addDays, formatAmount, todayIso } from "../lib/format";
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf"; import { exportInvoicePdf } from "../lib/pdf";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft"; import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import { InvoicePreview } from "../components/InvoicePreview"; import { InvoicePreview } from "../components/InvoicePreview";
import { ImagePicker } from "../components/ImagePicker"; import { ImagePicker } from "../components/ImagePicker";
import type { InvoicePdfProps } from "../pdf/InvoiceDocument"; import type { InvoicePdfProps } from "../lib/pdfProps";
interface NewInvoiceProps { interface NewInvoiceProps {
settings: Settings; settings: Settings;
@@ -523,8 +523,7 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
setExporting(true); setExporting(true);
setExportError(null); setExportError(null);
try { try {
const props = await buildPdfProps(invoice, settings, bank); const path = await exportInvoicePdf(invoice, settings, bank);
const path = await exportPdfFromProps(props, invoice.number);
setExportedPath(path ?? null); setExportedPath(path ?? null);
} catch (e) { } catch (e) {
setExportError(String(e)); setExportError(String(e));