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).
295 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|