Fix header overlap in invoice PDF and Bongbetic wordmark on light theme
react-pdf wraps text once at the first flex measure; when yoga then shrank the vendor column the cached lines ran under the INVOICE meta column. The header now pins column widths (299 + 24 + 192 pt), sizes the logo from its own aspect (stacked above the text when it would leave under 160 pt), uses absolute per-Text line heights, and turns hyphenation off. Also: repeating table header with an orphan-header guard, wrap=false rows, Page n of N footer, GSTIN printed only when present, one Text per address line, HTML twin header grid. The light-theme header showed the white-text wordmark; the ternary was inverted. Adds vitest + pdfjs-dist harness that fails on the old document (address overlaps Invoice No.) and passes on the new one.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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 { describeBox, findOverlaps, 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",
|
||||
};
|
||||
|
||||
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");
|
||||
|
||||
pdf.pages.forEach((page, idx) => {
|
||||
const k = idx + 1;
|
||||
const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`);
|
||||
expect(overlaps, `page ${k} overlaps`).toEqual([]);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user