Files
Voiced/src/pdf/InvoiceDocument.test.tsx
T
xavierk 3818b1eeed Add typography roles, text primitives, fontkit measurer and layout lint
Txt always sets an absolute line height; RenderTxt never does. Row pins
every cell and stacks when the fill column would be too narrow. IdText and
FitText shrink then chunk or truncate. The fontkit measurer matches react-pdf
text widths exactly. A TypeScript-AST lint test enforces the layout rules
(no lineHeight outside Txt, no non-literal fixed/wrap/minPresenceAhead,
flexGrow needs flexBasis, no stray absolute positioning).
2026-10-04 04:48:19 +05:30

295 lines
10 KiB
TypeScript

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<InvoicePdfProps> & { items: InvoiceItem[] }): InvoicePdfProps {
const subtotal = round2(over.items.reduce((s, it) => s + lineAmount(it), 0));
const discount = over.discount ?? 0;
const taxType = over.taxType ?? "cgst_sgst";
const taxRate = over.taxRate ?? 18;
const taxable = subtotal - discount;
const tax = taxType === "none" ? 0 : round2((taxable * taxRate) / 100);
const cgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
const sgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
const igst = taxType === "igst" ? tax : 0;
return {
vendor,
bank,
logoDataUri,
signatureDataUri: null,
number: "INV-0001",
invoiceDate: "2026-09-15",
dueDate: "2026-10-15",
clientName: "Acme Learning Pvt Ltd",
clientAddress: "42 MG Road\nBangalore 560001",
clientGstin: "29ABCDE1234F1Z5",
poNumber: "",
placeOfSupplyStateCode: "29",
subtotal,
discount,
taxType,
taxRate,
cgst,
sgst,
igst,
total: round2(taxable + cgst + sgst + igst),
amountInWords: "Rupees One Lakh Only",
notes: "",
...over,
};
}
const fixtureA = () =>
build({
items: makeItems(3),
notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest.",
poNumber: "PO-2026-0042",
});
const fixtureB = () => build({ items: makeItems(60) });
const fixtureD = () =>
build({
items: makeItems(5),
logoDataUri: null,
signatureDataUri: null,
vendor: {
...vendor,
vendorName: "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");
});
});