diff --git a/src/components/InvoicePreview.tsx b/src/components/InvoicePreview.tsx index 7b277ae..ceec329 100644 --- a/src/components/InvoicePreview.tsx +++ b/src/components/InvoicePreview.tsx @@ -1,7 +1,7 @@ import { UNIT_LABELS } from "../lib/types"; import { computeLineAmount } from "../lib/invoice"; import { formatAmount, formatDate } from "../lib/format"; -import type { InvoicePdfProps } from "../pdf/InvoiceDocument"; +import type { InvoicePdfProps } from "../lib/pdfProps"; const money = (n: number) => `₹${formatAmount(n)}`; diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx index f2a31ab..562939c 100644 --- a/src/lib/pdf.tsx +++ b/src/lib/pdf.tsx @@ -1,23 +1,23 @@ -import { pdf } from "@react-pdf/renderer"; import { save } from "@tauri-apps/plugin-dialog"; -import { registerPdfFonts } from "../pdf/fonts"; -import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument"; +import pkg from "../../package.json"; +import { DEFAULT_PREFS, type RenderPrefsV1 } from "../pdf/model/prefs"; +import { buildRenderModel } from "../pdf/model/build"; 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 { imageSizeFromDataUri } from "./imageSize"; +import type { InvoicePdfProps } from "./pdfProps"; import type { BankAccount, Invoice, Settings } from "./types"; -function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = String(reader.result ?? ""); - resolve(result.substring(result.indexOf(",") + 1)); - }; - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(blob); - }); +function bytesToBase64(bytes: Uint8Array): string { + let bin = ""; + const step = 0x8000; + for (let i = 0; i < bytes.length; i += step) bin += String.fromCharCode(...bytes.subarray(i, i + step)); + return btoa(bin); } +/** Flat props for the on-screen preview; the PDF itself is built by renderInvoicePdf. */ export async function buildPdfProps( invoice: Invoice, settings: Settings, @@ -60,26 +60,44 @@ export async function buildPdfProps( }; } -/** Render props straight to a PDF blob. */ -export async function renderPdfBlob(props: InvoicePdfProps): Promise { - registerPdfFonts(); - return pdf().toBlob(); +/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */ +const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 }; + +/** + * 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 { + 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. */ -export async function exportPdfFromProps( - props: InvoicePdfProps, - fileName: string, -): Promise { - const blob = await renderPdfBlob(props); - const dataBase64 = await blobToBase64(blob); +/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */ +export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise { const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice"; const path = await save({ defaultPath: `${suggested}.pdf`, filters: [{ name: "PDF document", extensions: ["pdf"] }], }); if (!path) return null; - await api.saveBinaryFile(path, dataBase64); + await api.saveBinaryFile(path, bytesToBase64(bytes)); return path; } @@ -87,7 +105,8 @@ export async function exportInvoicePdf( invoice: Invoice, settings: Settings, bank: BankAccount | null, + prefs: RenderPrefsV1 = DEFAULT_PREFS, ): Promise { - const props = await buildPdfProps(invoice, settings, bank); - return exportPdfFromProps(props, invoice.number); + const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs); + return savePdfBytes(bytes, invoice.number); } diff --git a/src/lib/pdfProps.ts b/src/lib/pdfProps.ts new file mode 100644 index 0000000..243f2aa --- /dev/null +++ b/src/lib/pdfProps.ts @@ -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; +} diff --git a/src/pdf/InvoiceDocument.test.tsx b/src/pdf/InvoiceDocument.test.tsx deleted file mode 100644 index 0971b2f..0000000 --- a/src/pdf/InvoiceDocument.test.tsx +++ /dev/null @@ -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/arun-p-logo.png"), -).toString("base64")}`; - -const vendor: Settings = { - vendorName: "Arun P", - vendorAddress: "13-B type House, Robertson Road, Frazer Town, Bangalore - 560005", - vendorEmail: "arunakira113@gmail.com", - vendorPhone: "9738284459", - vendorPan: "CPOPP7388K", - 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: "Arun P", - accountNo: "54058708786", - branch: "017060", - ifsc: "SBIN0017060", - 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 & { 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: "Arunachalam Padmanabhan Ramasubramanian Voice Studios Pvt", - vendorEmail: "arun.padmanabhan.voiceover@examplestudios.co.in", - vendorAddress: "13-B type House, Robertson Road\nFrazer 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"); - }); -}); diff --git a/src/pdf/InvoiceDocument.tsx b/src/pdf/InvoiceDocument.tsx deleted file mode 100644 index 2eed186..0000000 --- a/src/pdf/InvoiceDocument.tsx +++ /dev/null @@ -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; - -/** 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 ; -} - -interface LogoPlan { - stacked: boolean; - style: ComponentProps["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 ( - - - - - {logoDataUri && logo ? : null} - - {vendor.vendorName || "Vendor"} - {splitLines(vendor.vendorAddress).map((line, i) => ( - - {line} - - ))} - {vendor.vendorEmail ? {vendor.vendorEmail} : null} - {vendor.vendorPhone ? {vendor.vendorPhone} : null} - {vendor.vendorPan ? PAN: {vendor.vendorPan} : null} - {vendor.vendorGstin ? GSTIN: {vendor.vendorGstin} : null} - - - - - {isTaxInvoice ? "Tax Invoice" : "Invoice"} - - Invoice No. - {number} - - - Date - {dateFmt(invoiceDate)} - - {dueDate ? ( - - Due Date - {dateFmt(dueDate)} - - ) : null} - - - - - - - - Billed To - {clientName || "—"} - {splitLines(clientAddress).map((line, i) => ( - {line} - ))} - {clientGstin ? GSTIN: {clientGstin} : null} - {placeOfSupplyStateCode ? Place of Supply: {placeOfSupplyStateCode} : null} - - - {poNumber ? ( - <> - PO Number - {poNumber} - - ) : null} - - - - {/* Zero-height guard: keeps the header row from being stranded at the foot of a page. */} - - - - Description - Unit Price - Qty - Total - - {items.map((item, i) => ( - - - {(splitLines(item.description).length ? splitLines(item.description) : ["—"]).map( - (line, li) => ( - {line} - ), - )} - - - {item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"} - - {item.mode === "rate" ? item.quantity : 1} - {money(computeLineAmount(item))} - - ))} - - - - - - Subtotal - {money(subtotal)} - - {discount > 0 ? ( - - Discount - - {money(discount)} - - ) : null} - {taxType === "cgst_sgst" ? ( - <> - - CGST ({(taxRate / 2).toFixed(2)}%) - {money(cgst)} - - - SGST ({(taxRate / 2).toFixed(2)}%) - {money(sgst)} - - - ) : null} - {taxType === "igst" ? ( - - IGST ({taxRate.toFixed(2)}%) - {money(igst)} - - ) : null} - - Total - {money(total)} - - - - - - Amount in words: {amountInWords} - - - {notes ? ( - - Notes - {splitLines(notes).map((line, i) => ( - {line} - ))} - - ) : null} - - - - {bank ? ( - <> - Pay To - {bank.accountName || vendor.vendorName} - {bank.bankName} - {bank.accountNo ? Account No.: {bank.accountNo} : null} - {bank.branch ? Branch: {bank.branch} : null} - {bank.ifsc ? IFSC: {bank.ifsc} : null} - - ) : null} - - - - {signatureDataUri ? ( - <> - - {vendor.vendorName} - - ) : ( - <> - {vendor.vendorName} - Computer-generated invoice; no signature is required. - - )} - - - - - {vendor.vendorName} - {"Generated with Voiced — a Bongbetic product"} - - totalPages ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}` - } - /> - - - - ); -} diff --git a/src/pdf/InvoicePage.tsx b/src/pdf/InvoicePage.tsx new file mode 100644 index 0000000..2a5dda5 --- /dev/null +++ b/src/pdf/InvoicePage.tsx @@ -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 `` 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 ( + + {children} + }]} + /> + + ); +} diff --git a/src/pdf/InvoicePdf.tsx b/src/pdf/InvoicePdf.tsx new file mode 100644 index 0000000..35f2c48 --- /dev/null +++ b/src/pdf/InvoicePdf.tsx @@ -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 ( + + + + + + ); +} diff --git a/src/pdf/blocks/ItemsTable.tsx b/src/pdf/blocks/ItemsTable.tsx index 4fe5f46..99adc8b 100644 --- a/src/pdf/blocks/ItemsTable.tsx +++ b/src/pdf/blocks/ItemsTable.tsx @@ -10,6 +10,11 @@ import { pdfFlags } from "./pdfProps"; import { Cell, Row } from "./Row"; import { Txt } from "./Txt"; +/** A cell: one string, or several (one Txt each, stacked), for multi-line values. */ +export type TableCellValue = string | string[]; + +const cellParts = (v: TableCellValue): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]); + export interface TableRoles { head: RoleName | TypeToken; body: RoleName | TypeToken; @@ -22,6 +27,8 @@ export interface TableLook { padY?: number; /** Bottom border of each row in pt (counted in the row height). Default 0. */ border?: number; + /** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */ + padRight?: number[]; faceOverrides?: Readonly>; } @@ -30,8 +37,8 @@ export interface ItemsTablePlanInput extends TableLook { gap: number; /** Table width in pt (the content width of the page, or of the cell holding the table). */ width: number; - header: string[]; - rows: string[][]; + header: TableCellValue[]; + rows: TableCellValue[][]; measurer: Measurer; roles: TableRoles; /** Height of whatever follows the last row and must stay with it (the totals). */ @@ -49,20 +56,23 @@ export interface ItemsTablePlan { rowLines: number[]; } -function cellHeight(m: Measurer, role: RoleName | TypeToken, text: string, inner: number, look: TableLook): { lines: number; height: number } { +function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } { const t = roleToken(role); if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan"); - if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan"); - const lines = Math.max( - 1, - m.wrapLines(drawnText(t, text), inner, { - face: resolveFace(t.face, look.faceOverrides), - weight: t.weight, - italic: t.italic ?? false, - size: t.size, - letterSpacing: (t.tracking ?? 0) * t.size, - }).length, - ); + let lines = 0; + for (const text of cellParts(value)) { + if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan"); + lines += Math.max( + 1, + m.wrapLines(drawnText(t, text), inner, { + face: resolveFace(t.face, look.faceOverrides), + weight: t.weight, + italic: t.italic ?? false, + size: t.size, + letterSpacing: (t.tracking ?? 0) * t.size, + }).length, + ); + } return { lines, height: lines * t.leading }; } @@ -84,8 +94,8 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan { for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan"); const { widths } = colsDetailed(width, columns, gap); - const inner = widths.map((w) => Math.max(0, w - 2 * padX)); - const heightOf = (role: RoleName | TypeToken, cells: string[]) => { + const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0))); + const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => { const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input)); return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border }; }; @@ -110,8 +120,8 @@ export interface TableRowsProps extends TableLook { columns: ColSpec[]; gap: number; width: number; - header: string[]; - rows: string[][]; + header: TableCellValue[]; + rows: TableCellValue[][]; roles: TableRoles; /** Per column; default left. */ align?: Array<"left" | "right" | "center">; @@ -122,6 +132,8 @@ export interface TableRowsProps extends TableLook { headerStyle?: Record; rowStyle?: Record; borderColor?: string; + /** Text colour of the header cells. */ + headColor?: string; } /** @@ -129,7 +141,7 @@ export interface TableRowsProps extends TableLook { * `wrap={false}` row per item. The container has no padding or border; those go on the rows, because * the repeated header is positioned against the container's top. */ -export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, ...look }: TableRowsProps) { +export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, ...look }: TableRowsProps) { const padX = look.padX ?? 0; const padY = look.padY ?? 4; const border = look.border ?? 0; @@ -138,12 +150,14 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = [] ...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}), ...extra, }); - const cells = (role: RoleName | TypeToken, values: string[]) => + const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) => values.map((v, i) => ( - - - {v} - + + {cellParts(v).map((part, li) => ( + + {part} + + ))} )); @@ -151,7 +165,7 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = [] - {cells(roles.head, header)} + {cells(roles.head, header, headColor)} {rows.map((r, i) => ( 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`; @@ -48,6 +68,8 @@ export function pageLabelText(pageNumber: number, totalPages: number | undefined export function PageChrome({ frame, footerLeft, + footerLeftWidth, + footerCenter, pageLabel = true, role = "footer", color, @@ -57,7 +79,7 @@ export function PageChrome({ }: PageChromeProps) { const { pad } = frame; const need = chromeHeight(role); - const showFooter = pageLabel || footerLeft !== undefined; + const showFooter = pageLabel || footerLeft !== undefined || footerCenter !== undefined; 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"); } @@ -105,7 +127,16 @@ export function PageChrome({ style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }} {...pdfFlags({ fixed: true })} > - {footerLeft !== undefined && footerLeft} />} + {footerLeft !== undefined && + (footerLeftWidth !== undefined ? ( + + {footerLeft} + + ) : ( + footerLeft} /> + ))} + {footerCenter !== undefined && footerCenter} />} + {footerCenter === undefined && footerLeftWidth !== undefined && } {pageLabel && ( + {kids.map((child, i) => ( 0 ? { marginTop: gap } : {}) } as never}> {child} @@ -49,7 +49,7 @@ export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", wid } return ( - + {kids.map((child, i) => { const isFill = "fill" in widths[i]; const box = isFill ? FILL : pinned(w[i]); diff --git a/src/pdf/blocks/SafeTxt.tsx b/src/pdf/blocks/SafeTxt.tsx new file mode 100644 index 0000000..c5bf42f --- /dev/null +++ b/src/pdf/blocks/SafeTxt.tsx @@ -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 { + 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 ( + + {children} + + ); + } + return ( + + {children} + + ); +} diff --git a/src/pdf/blocks/Txt.tsx b/src/pdf/blocks/Txt.tsx index 088a7cd..4b605e2 100644 --- a/src/pdf/blocks/Txt.tsx +++ b/src/pdf/blocks/Txt.tsx @@ -18,6 +18,8 @@ export interface TxtStyleProps { } 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; /** Clamp to this many lines and end with an ellipsis. */ maxLines?: number; @@ -56,7 +58,7 @@ export function assertNoLineHeight(style: Record | undefined, w * 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. */ -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); if (token.leading === undefined) throw new Error("Txt needs a role with a leading; use RenderTxt for render-prop text"); assertNoLineHeight(style, "Txt"); @@ -68,5 +70,5 @@ export function Txt({ role, color, align, weight, italic, maxLines, width, grow, ...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}), ...style, }; - return {children as ReactNode}; + return {children as ReactNode}; } diff --git a/src/pdf/blocks/pagination.test.tsx b/src/pdf/blocks/pagination.test.tsx index 09a6085..11978b9 100644 --- a/src/pdf/blocks/pagination.test.tsx +++ b/src/pdf/blocks/pagination.test.tsx @@ -86,6 +86,23 @@ describe("PageChrome", () => { 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 = "Arunachalam Padmanabhan 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("Arunachalam"))!; + 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", () => { const tight = frameFor({ footerReserve: 4 }); 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)); }); + 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", () => { const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 }; const p = plan(); diff --git a/src/pdf/blocks/slots.tsx b/src/pdf/blocks/slots.tsx new file mode 100644 index 0000000..e13e9dc --- /dev/null +++ b/src/pdf/blocks/slots.tsx @@ -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 ( + + {`GSTIN: ${value}`} + + ); +} + +function PlaceOfSupply({ model, role = "body", color }: { model: RenderModel; role?: Role; color?: string }) { + if (!model.client.posLabel) return null; + return ( + + {`Place of Supply: ${model.client.posLabel}`} + + ); +} + +/** 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; +} + +/** 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 ( + + {model.totals.taxRows.map((r, i) => ( + + {r.rateText ? `${r.label} (${r.rateText})` : r.label} + {r.amountText} + + ))} + + ); +} + +function AmountInWords({ model, role = "body", style }: { model: RenderModel; role?: Role; style?: Record }) { + if (!model.amountInWords) return null; + return ( + + {`Amount in words: ${model.amountInWords}`} + + ); +} + +function ReverseCharge({ model, role = "body", color, style }: { model: RenderModel; role?: Role; color?: string; style?: Record }) { + if (!model.reverseCharge) return null; + return ( + + {model.reverseCharge} + + ); +} + +/** 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 ( + + {dataUri ? ( + // Pinned box (an Image may be pinned); the picture sits at its right and bottom edge, just above the rule. + + ) : null} + + {name} + {designation ? ( + {designation} + ) : null} + + {!dataUri ? ( + + {FINE_LINE} + + ) : null} + + ); +} + +export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge }; diff --git a/src/pdf/engine/ids.ts b/src/pdf/engine/ids.ts index 9ea79cb..9c4857f 100644 --- a/src/pdf/engine/ids.ts +++ b/src/pdf/engine/ids.ts @@ -17,3 +17,9 @@ export const TABLE_HEAD_ID = "tbl-head"; /** Body rows are `tbl-row-`: unique, so a row on two pages is visible as a split. */ 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"; diff --git a/src/pdf/engine/logo.test.ts b/src/pdf/engine/logo.test.ts new file mode 100644 index 0000000..d4aa512 --- /dev/null +++ b/src/pdf/engine/logo.test.ts @@ -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 Arun P 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); + }); +}); diff --git a/src/pdf/engine/logo.ts b/src/pdf/engine/logo.ts new file mode 100644 index 0000000..d31627f --- /dev/null +++ b/src/pdf/engine/logo.ts @@ -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 Canva 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. + */ diff --git a/src/pdf/engine/textplan.ts b/src/pdf/engine/textplan.ts new file mode 100644 index 0000000..7c60e64 --- /dev/null +++ b/src/pdf/engine/textplan.ts @@ -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>; +} + +/** 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; +} diff --git a/src/pdf/fonts.ts b/src/pdf/fonts.ts deleted file mode 100644 index f89de13..0000000 --- a/src/pdf/fonts.ts +++ /dev/null @@ -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); -} diff --git a/src/pdf/lint.test.ts b/src/pdf/lint.test.ts index a241c2e..24c8a4f 100644 --- a/src/pdf/lint.test.ts +++ b/src/pdf/lint.test.ts @@ -6,15 +6,12 @@ import { formatViolation, lintSource } from "./testing/layoutLint"; const ROOT = process.cwd(); 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[] { return readdirSync(dir).flatMap((name) => { const full = path.join(dir, name); if (statSync(full).isDirectory()) return sources(full); 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]; }); } diff --git a/src/pdf/model/build.test.ts b/src/pdf/model/build.test.ts index 4ce8736..5df658a 100644 --- a/src/pdf/model/build.test.ts +++ b/src/pdf/model/build.test.ts @@ -97,7 +97,7 @@ describe("unregistered invoice", () => { expect(m.docTitle).toBe("Invoice"); expect(m.docType).toBe("invoice"); 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({ subtotalText: "₹5,500.50", discountText: null, @@ -119,7 +119,7 @@ describe("unregistered invoice", () => { expect(m.client.addressLines).toEqual(["1 Main Road", "Mumbai"]); expect(m.client.poNumber).toBeNull(); 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", () => { expect(m.items[0]).toEqual({ @@ -179,16 +179,24 @@ describe("CGST + SGST tax invoice", () => { expect(m.totals.discountText).toBe("₹500.50"); expect(m.totals.taxableText).toBe("₹5,000.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.gstin).toBe("29ABCDE1234F1Z5"); expect(m.client.poNumber).toBe("PO-77"); 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" }); }); }); +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", () => { 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", () => { diff --git a/src/pdf/model/build.ts b/src/pdf/model/build.ts index 555cee6..083d85b 100644 --- a/src/pdf/model/build.ts +++ b/src/pdf/model/build.ts @@ -58,7 +58,7 @@ export interface RenderModel { stateCode: string; 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: { name: string; addressLines: string[]; @@ -83,6 +83,7 @@ export interface RenderModel { logo: LogoInput | null; footerText: string; appVersion: string; + /** "Tax is payable on reverse charge: Yes/No" on a tax invoice; null on a plain invoice. */ reverseCharge: string | null; cancelled: boolean; /** Which regulated slots have content, for the template contract tests. */ @@ -92,6 +93,7 @@ export interface RenderModel { pos: boolean; hsn: boolean; taxRows: boolean; + reverseCharge: boolean; }; } @@ -195,6 +197,7 @@ export function buildRenderModel(input: BuildInput): RenderModel { numberLabel: "Invoice No.", number: sanitize(invoice.number), date: formatDate(invoice.invoiceDate), + isoDate: invoice.invoiceDate, dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : null, }, client: { @@ -223,7 +226,7 @@ export function buildRenderModel(input: BuildInput): RenderModel { logo, footerText: "Generated with Voiced — a Bongbetic product", 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", slots: { gstin: showGstin, @@ -231,6 +234,7 @@ export function buildRenderModel(input: BuildInput): RenderModel { pos: posLabel !== null, hsn, taxRows: taxRows.length > 0, + reverseCharge: docType === "tax_invoice", }, }; } diff --git a/src/pdf/model/prefs.test.ts b/src/pdf/model/prefs.test.ts new file mode 100644 index 0000000..c8be25c --- /dev/null +++ b/src/pdf/model/prefs.test.ts @@ -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); + }); +}); diff --git a/src/pdf/model/prefs.ts b/src/pdf/model/prefs.ts new file mode 100644 index 0000000..71a1718 --- /dev/null +++ b/src/pdf/model/prefs.ts @@ -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; + 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); diff --git a/src/pdf/model/slots.ts b/src/pdf/model/slots.ts new file mode 100644 index 0000000..b8ed1c1 --- /dev/null +++ b/src/pdf/model/slots.ts @@ -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 = { + 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 []; + } +} diff --git a/src/pdf/render/browserInit.ts b/src/pdf/render/browserInit.ts new file mode 100644 index 0000000..e7cfdb1 --- /dev/null +++ b/src/pdf/render/browserInit.ts @@ -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; +} + +async function defaultFetchBytes(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +let ready: Promise | 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 { + if (!ready) { + ready = run(opts).catch((err) => { + ready = null; + throw err; + }); + } + return ready; +} + +async function run({ base = "/fonts", fetchBytes = defaultFetchBytes }: BrowserInitOptions): Promise { + const bytesByUrl = new Map(); + 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; +} diff --git a/src/pdf/render/invoice.ts b/src/pdf/render/invoice.ts new file mode 100644 index 0000000..cea794c --- /dev/null +++ b/src/pdf/render/invoice.ts @@ -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 { + 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 }; +} diff --git a/src/pdf/templates/classic/Layout.tsx b/src/pdf/templates/classic/Layout.tsx new file mode 100644 index 0000000..bece592 --- /dev/null +++ b/src/pdf/templates/classic/Layout.tsx @@ -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 & { children?: TxtChild }) { + const w = useAvailableWidth(); + return ( + + {children} + + ); +} + +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 = ( + + + {s.name} + + {s.addressLines.map((line, i) => ( + + {line} + + ))} + {s.email ? ( + + {s.email} + + ) : null} + {s.phone ? ( + + {s.phone} + + ) : null} + {s.pan ? ( + {`PAN: ${s.pan}`} + ) : null} + + + ); + const image = logo ? ( + + ) : null; + if (beside) { + return ( + + {image} + {text} + + ); + } + return ( + + {image} + {text} + + ); +} + +function CancelledTag() { + const label = TYPE_ROLES.label; + const w = Math.ceil(measureToken(getMeasurer(), label, "Cancelled")) + 2; + return ( + + + Cancelled + + + ); +} + +function MetaRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value} + + ); +} + +function MetaValue({ children }: { children: string }) { + const w = useAvailableWidth(); + return ( + + {children} + + ); +} + +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 ( + + + {model.docTitle} + + {model.cancelled ? : null} + + + {model.meta.dueDate ? : null} + + ); +} + +function PartyBlock({ model, gaps }: { model: RenderModel; gaps: Gaps }) { + const c = model.client; + return ( + + + + Billed To + + {c.name} + {c.addressLines.map((line, i) => ( + + {line} + + ))} + + + + + {c.poNumber ? ( + <> + + PO Number + + {c.poNumber} + + ) : null} + + + ); +} + +function TotalsRow({ label, amount }: { label: string; amount: string }) { + return ( + + {label} + + {amount} + + + ); +} + +function TotalsBlock({ model, gaps }: { model: RenderModel; gaps: Gaps }) { + const t = model.totals; + return ( + + + + + + {t.discountText ? : null} + {t.taxableText ? : null} + + + Total + + {t.totalText} + + + + + + + + ); +} + +function BankBlock({ model }: { model: RenderModel }) { + const bank = model.bank; + if (!bank) return ; + return ( + + + Pay To + + {bank.accountName} + {bank.lines.map((l, i) => ( + + {l.label === "Bank" ? l.value : `${l.label}: ${l.value}`} + + ))} + + ); +} + +export function ClassicLayout({ model, frame, logo }: TemplateProps) { + const plan = planClassic(model, frame); + const { gaps } = plan; + const [, metaW] = headerCols(frame.contentWidth); + + return ( + <> + + + + + + + + + + + + {/* A TableGuard prop, not a node flag (it spreads pdfFlags itself); the spread keeps the flag lint quiet. */} + + } + /> + + {model.notesLines.length > 0 ? ( + + {/* + 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. + */} + + + Notes + + {model.notesLines[0]} + + {model.notesLines.slice(1).map((line, i) => ( + + {line} + + ))} + + ) : null} + + + + + + + + {model.cancelled ? ( + + {CANCELLED_SENTENCE} + + ) : null} + + + ); +} + diff --git a/src/pdf/templates/classic/classic.test.tsx b/src/pdf/templates/classic/classic.test.tsx new file mode 100644 index 0000000..c0dda23 --- /dev/null +++ b/src/pdf/templates/classic/classic.test.tsx @@ -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) { + 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); + }); +}); diff --git a/src/pdf/templates/classic/ids.ts b/src/pdf/templates/classic/ids.ts new file mode 100644 index 0000000..8550c76 --- /dev/null +++ b/src/pdf/templates/classic/ids.ts @@ -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"; diff --git a/src/pdf/templates/classic/index.ts b/src/pdf/templates/classic/index.ts new file mode 100644 index 0000000..29ba9d8 --- /dev/null +++ b/src/pdf/templates/classic/index.ts @@ -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, +}; diff --git a/src/pdf/templates/classic/plan.ts b/src/pdf/templates/classic/plan.ts new file mode 100644 index 0000000..84423ed --- /dev/null +++ b/src/pdf/templates/classic/plan.ts @@ -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; + +export type Gaps = { -readonly [K in keyof typeof GAP_RANGES]: number }; + +export function elasticGaps(frame: Pick): Gaps { + const keys = Object.keys(GAP_RANGES) as Array; + 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[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."; diff --git a/src/pdf/templates/contract.ts b/src/pdf/templates/contract.ts new file mode 100644 index 0000000..faa3f16 --- /dev/null +++ b/src/pdf/templates/contract.ts @@ -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; +} + +/** 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); +} diff --git a/src/pdf/templates/harness.test.tsx b/src/pdf/templates/harness.test.tsx new file mode 100644 index 0000000..8bf1eb4 --- /dev/null +++ b/src/pdf/templates/harness.test.tsx @@ -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> = { + 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)); + } + }); + } +}); diff --git a/src/pdf/templates/harness.ts b/src/pdf/templates/harness.ts new file mode 100644 index 0000000..07bfe0d --- /dev/null +++ b/src/pdf/templates/harness.ts @@ -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; + invoice?: Partial; + /** 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: "Arun P", + vendorAddress: "13-B type House, Robertson Road, Frazer Town, Bangalore - 560005", + vendorEmail: "arunakira113@gmail.com", + vendorPhone: "9738284459", + vendorPan: "CPOPP7388K", + 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: "Arun P", + accountNo: "54058708786", + branch: "017060", + ifsc: "SBIN0017060", + 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(); +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 Arun P logo, a 3:1 aspect. + const png = readFileSync(path.resolve(process.cwd(), "public/brand/arun-p-logo.png")); + hit = { dataUri: dataUri(png), width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; + } else if (kind === "wide") { + // The trimmed Arun P 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: "Arun P", 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: "AP/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: "arun.padmanabhan.voiceover.studio.bangalore@examplestudios-india.co.in", + vendorPhone: "+91 97382 84459 / +91 80 4123 9988 (studio)", + vendorName: "Arunachalam Padmanabhan 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 => ({ ...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 { + ensureSetup(); + const fixture = buildFixture(spec); + const result = await renderInvoicePdf({ model: fixture.model, prefs }); + const pdf = await parsePdfText(result.bytes); + return { fixture, prefs, result, pdf }; +} diff --git a/src/pdf/templates/registry.ts b/src/pdf/templates/registry.ts new file mode 100644 index 0000000..f523400 --- /dev/null +++ b/src/pdf/templates/registry.ts @@ -0,0 +1,13 @@ +import type { TemplateDefinition } from "./contract"; +import { classic } from "./classic"; + +export const TEMPLATES: Record = { + 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]; +} diff --git a/src/pdf/templates/slots.lint.test.ts b/src/pdf/templates/slots.lint.test.ts new file mode 100644 index 0000000..55115a4 --- /dev/null +++ b/src/pdf/templates/slots.lint.test.ts @@ -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([]); + }); +}); diff --git a/src/pdf/testing/pdfText.ts b/src/pdf/testing/pdfText.ts index 6164a29..72c6452 100644 --- a/src/pdf/testing/pdfText.ts +++ b/src/pdf/testing/pdfText.ts @@ -28,11 +28,15 @@ export interface RenderedPdf { export async function renderPdfText(element: ReactElement): Promise { // The renderToBuffer type wants a element; any element that renders one works. const buf = await renderToBuffer(element as Parameters[0]); - const raw = buf.toString("latin1"); - const bytes = new Uint8Array(buf); + return parsePdfText(new Uint8Array(buf)); +} + +/** The text boxes pdf.js finds on each page of already-rendered PDF bytes. */ +export async function parsePdfText(bytes: Uint8Array): Promise { + const raw = Buffer.from(bytes).toString("latin1"); const task = getDocument({ - data: new Uint8Array(buf), + data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true, }); diff --git a/src/pdf/testing/rasterize.ts b/src/pdf/testing/rasterize.ts index e6ceca1..89b5427 100644 --- a/src/pdf/testing/rasterize.ts +++ b/src/pdf/testing/rasterize.ts @@ -38,3 +38,21 @@ export async function inkPixels( 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 { + 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(); + } +} diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 45a9e28..d8e9a5c 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -51,12 +51,12 @@ import { computeLineAmount, computeTotals } from "../lib/invoice"; import { amountInWords } from "../lib/numberToWords"; import { gstinFullError } from "../lib/validators"; 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 { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts"; import { InvoicePreview } from "../components/InvoicePreview"; import { ImagePicker } from "../components/ImagePicker"; -import type { InvoicePdfProps } from "../pdf/InvoiceDocument"; +import type { InvoicePdfProps } from "../lib/pdfProps"; interface NewInvoiceProps { settings: Settings; @@ -523,8 +523,7 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP setExporting(true); setExportError(null); try { - const props = await buildPdfProps(invoice, settings, bank); - const path = await exportPdfFromProps(props, invoice.number); + const path = await exportInvoicePdf(invoice, settings, bank); setExportedPath(path ?? null); } catch (e) { setExportError(String(e));