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:
2026-10-04 03:57:47 +05:30
parent d2ee9e32b3
commit f7a1302e94
12 changed files with 1269 additions and 126 deletions
+3 -1
View File
@@ -29,8 +29,10 @@ interface AppShellProps {
}
export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: AppShellProps) {
// The Carbon header background follows the theme: light on g10, dark on g100.
// The "dark" wordmark has dark text for light surfaces; "light" has white text for dark ones.
const wordmark =
theme === "g100" ? "/brand/bongbetic-wordmark-light.png" : "/brand/bongbetic-wordmark-dark.png";
theme === "g100" ? "/brand/bongbetic-wordmark-dark.png" : "/brand/bongbetic-wordmark-light.png";
return (
<>
+3 -3
View File
@@ -34,7 +34,6 @@ export function InvoicePreview(props: InvoicePdfProps) {
} = props;
const isTaxInvoice = taxType !== "none";
const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" · ");
return (
<div className="inv-preview-wrap">
@@ -45,9 +44,10 @@ export function InvoicePreview(props: InvoicePdfProps) {
<div>
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
{contact ? <p className="inv-vendor__line">{contact}</p> : null}
{vendor.vendorEmail ? <p className="inv-vendor__line">{vendor.vendorEmail}</p> : null}
{vendor.vendorPhone ? <p className="inv-vendor__line">{vendor.vendorPhone}</p> : null}
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
<p className="inv-vendor__line">GSTIN: {vendor.vendorGstin || "NA"}</p>
{vendor.vendorGstin ? <p className="inv-vendor__line">GSTIN: {vendor.vendorGstin}</p> : null}
</div>
</div>
<div className="inv-meta">
+62
View File
@@ -0,0 +1,62 @@
export interface ImageSize {
width: number;
height: number;
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
const u16 = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1];
const u32 = (b: Uint8Array, i: number) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
function pngSize(b: Uint8Array): ImageSize | null {
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (b.length < 24 || sig.some((v, i) => b[i] !== v)) return null;
return { width: u32(b, 16), height: u32(b, 20) };
}
function jpegSize(b: Uint8Array): ImageSize | null {
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
let i = 2;
while (i + 3 < b.length) {
if (b[i] !== 0xff) {
i++;
continue;
}
const marker = b[i + 1];
if (marker === 0xff) {
i++; // fill byte
continue;
}
// Standalone markers carry no length.
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
i += 2;
continue;
}
const isSof =
marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
if (isSof) {
if (i + 8 >= b.length) return null;
return { height: u16(b, i + 5), width: u16(b, i + 7) };
}
i += 2 + u16(b, i + 2);
}
return null;
}
/** Pixel size of a PNG or JPEG data URI, or null when it is another format or malformed. */
export function imageSizeFromDataUri(uri: string): ImageSize | null {
const comma = uri.indexOf(",");
if (!uri.startsWith("data:") || comma < 0) return null;
try {
const bytes = base64ToBytes(uri.slice(comma + 1));
const size = pngSize(bytes) ?? jpegSize(bytes);
return size && size.width > 0 && size.height > 0 ? size : null;
} catch {
return null;
}
}
+2
View File
@@ -1,5 +1,6 @@
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 { api } from "./api";
import type { BankAccount, Invoice, Settings } from "./types";
@@ -64,6 +65,7 @@ export async function buildPdfProps(
/** Render props straight to a PDF blob. */
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> {
registerPdfFonts();
return pdf(<InvoiceDocument {...props} />).toBlob();
}
+291
View File
@@ -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");
});
});
+242 -116
View File
@@ -1,41 +1,30 @@
import { Document, Font, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
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";
Font.register({
family: "IBM Plex Sans",
fonts: [
{ src: "/fonts/IBMPlexSans-Light.woff", fontWeight: 300 },
{ src: "/fonts/IBMPlexSans-Regular.woff", fontWeight: 400 },
{ src: "/fonts/IBMPlexSans-Italic.woff", fontWeight: 400, fontStyle: "italic" },
{ src: "/fonts/IBMPlexSans-Medium.woff", fontWeight: 500 },
{ src: "/fonts/IBMPlexSans-SemiBold.woff", fontWeight: 600 },
{ src: "/fonts/IBMPlexSans-Bold.woff", fontWeight: 700 },
],
});
Font.register({
family: "IBM Plex Mono",
fonts: [
{ src: "/fonts/IBMPlexMono-Regular.woff", fontWeight: 400 },
{ src: "/fonts/IBMPlexMono-SemiBold.woff", fontWeight: 600 },
],
});
import { imageSizeFromDataUri, type ImageSize } from "../lib/imageSize";
const amountFmt = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const money = (n: number) => `${"\u20B9"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
const money = (n: number) => `${"₹"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
const dateFmt = (iso: string) => {
if (!iso) return "\u2014";
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;
@@ -62,6 +51,29 @@ export interface InvoicePdfProps {
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",
@@ -70,32 +82,67 @@ const styles = StyleSheet.create({
paddingTop: 40,
paddingBottom: 48,
paddingHorizontal: 40,
lineHeight: 1.45,
},
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" },
vendor: { flexDirection: "row", maxWidth: "58%" },
logo: { maxHeight: 54, maxWidth: 170, marginRight: 14, objectFit: "contain" },
vendorName: { fontSize: 18, fontWeight: 600, marginBottom: 3 },
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: { textAlign: "right", maxWidth: "40%" },
title: { fontSize: 18, fontWeight: 600, letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 8 },
metaRow: { flexDirection: "row", justifyContent: "space-between", gap: 14 },
metaLabel: { color: "#525252" },
metaValue: { fontWeight: 600 },
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", justifyContent: "space-between", gap: 24 },
blockLabel: { fontSize: 7.5, letterSpacing: 1, textTransform: "uppercase", color: "#525252", marginBottom: 3 },
split: { flexDirection: "row", gap: HEADER_GAP },
blockLabel: {
fontSize: 7.5,
lineHeight: "10pt",
letterSpacing: 1,
textTransform: "uppercase",
color: "#525252",
marginBottom: 3,
},
strong: { fontWeight: 600 },
block: { maxWidth: "48%" },
table: { marginTop: 22, borderTopWidth: 1.4, borderTopColor: "#161616" },
tr: { flexDirection: "row", borderBottomWidth: 0.6, borderBottomColor: "#e0e0e0", paddingVertical: 6 },
th: { fontSize: 7.5, letterSpacing: 0.8, textTransform: "uppercase", color: "#525252", paddingBottom: 4 },
colDesc: { flex: 5 },
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: { width: 250 },
totals: { ...pinned(250) },
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
grand: {
flexDirection: "row",
@@ -104,12 +151,13 @@ const styles = StyleSheet.create({
borderTopColor: "#161616",
marginTop: 4,
paddingTop: 6,
fontSize: 13,
fontWeight: 600,
},
grandText: { fontSize: 13, lineHeight: "17pt", fontWeight: 600 },
words: { marginTop: 12, fontStyle: "italic" },
bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", marginTop: 34 },
signature: { textAlign: "right", minWidth: 180 },
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,
@@ -118,22 +166,77 @@ const styles = StyleSheet.create({
borderTopColor: "#8d8d8d",
fontWeight: 600,
},
fineprint: { fontSize: 7.5, color: "#6f6f6f", textAlign: "right", marginTop: 6, maxWidth: 220 },
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",
justifyContent: "space-between",
borderTopWidth: 0.6,
borderTopColor: "#e0e0e0",
paddingTop: 6,
},
footerLeft: {
...pinned(170),
fontSize: 7.5,
color: "#6f6f6f",
maxLines: 1,
textOverflow: "ellipsis",
},
footerCenter: { flexGrow: 1, flexBasis: 0, fontSize: 7.5, color: "#6f6f6f", textAlign: "center" },
footerRight: { ...pinned(90), fontSize: 7.5, color: "#6f6f6f", textAlign: "right" },
});
type TxtProps = PropsWithChildren<TextProps>;
/** Text with the body size and an absolute line height; later styles override both together. */
function Txt({ style, ...rest }: TxtProps) {
const extra = Array.isArray(style) ? style : style ? [style] : [];
return <Text {...rest} style={[styles.body, ...extra]} />;
}
interface LogoPlan {
stacked: boolean;
style: ComponentProps<typeof Image>["style"];
}
/**
* Size the logo from its own aspect inside a 170 x 54 pt box. When it would leave the supplier
* text less than 160 pt, the logo goes above the text instead of beside it.
*/
function planLogo(size: ImageSize | null): LogoPlan {
if (!size) {
return {
stacked: true,
style: {
...pinned(LOGO_MAX_W),
height: LOGO_MAX_H,
objectFit: "contain",
objectPositionX: 0,
marginBottom: 8,
},
};
}
const aspect = size.width / size.height;
const h = Math.min(LOGO_MAX_H, LOGO_MAX_W / aspect);
const w = h * aspect;
const stacked = SUPPLIER_W - w - LOGO_GAP < MIN_TEXT_W;
return {
stacked,
style: {
...pinned(w),
height: h,
objectFit: "contain",
marginRight: stacked ? 0 : LOGO_GAP,
marginBottom: stacked ? 8 : 0,
},
};
}
export function InvoiceDocument(props: InvoicePdfProps) {
const {
vendor,
@@ -162,37 +265,42 @@ export function InvoiceDocument(props: InvoicePdfProps) {
} = props;
const isTaxInvoice = taxType !== "none";
const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" \u00B7 ");
const logo = logoDataUri ? planLogo(imageSizeFromDataUri(logoDataUri)) : null;
return (
<Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View style={styles.vendor}>
{logoDataUri ? <Image style={styles.logo} src={logoDataUri} /> : null}
<View>
<Text style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Text>
<Text style={styles.vendorLine}>{vendor.vendorAddress}</Text>
{contact ? <Text style={styles.vendorLine}>{contact}</Text> : null}
{vendor.vendorPan ? <Text style={styles.vendorLine}>PAN: {vendor.vendorPan}</Text> : null}
<Text style={styles.vendorLine}>GSTIN: {vendor.vendorGstin || "NA"}</Text>
<View style={[styles.supplier, logo?.stacked ? styles.supplierStacked : styles.supplierRow]}>
{logoDataUri && logo ? <Image style={logo.style} src={logoDataUri} /> : null}
<View style={logo?.stacked ? styles.supplierTextStacked : styles.supplierTextRow}>
<Txt style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Txt>
{splitLines(vendor.vendorAddress).map((line, i) => (
<Txt key={`addr-${i}`} style={styles.vendorLine}>
{line}
</Txt>
))}
{vendor.vendorEmail ? <Txt style={styles.vendorLine}>{vendor.vendorEmail}</Txt> : null}
{vendor.vendorPhone ? <Txt style={styles.vendorLine}>{vendor.vendorPhone}</Txt> : null}
{vendor.vendorPan ? <Txt style={styles.vendorLine}>PAN: {vendor.vendorPan}</Txt> : null}
{vendor.vendorGstin ? <Txt style={styles.vendorLine}>GSTIN: {vendor.vendorGstin}</Txt> : null}
</View>
</View>
<View style={styles.meta}>
<Text style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Text>
<Txt style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Txt>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Invoice No.</Text>
<Text style={styles.metaValue}>{number}</Text>
<Txt style={styles.metaLabel}>Invoice No.</Txt>
<Txt style={styles.metaValue}>{number}</Txt>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Date</Text>
<Text style={styles.metaValue}>{dateFmt(invoiceDate)}</Text>
<Txt style={styles.metaLabel}>Date</Txt>
<Txt style={styles.metaValue}>{dateFmt(invoiceDate)}</Txt>
</View>
{dueDate ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Due Date</Text>
<Text style={styles.metaValue}>{dateFmt(dueDate)}</Text>
<Txt style={styles.metaLabel}>Due Date</Txt>
<Txt style={styles.metaValue}>{dateFmt(dueDate)}</Txt>
</View>
) : null}
</View>
@@ -201,98 +309,112 @@ export function InvoiceDocument(props: InvoicePdfProps) {
<View style={styles.divider} />
<View style={styles.split}>
<View style={styles.block}>
<Text style={styles.blockLabel}>Billed To</Text>
<Text style={styles.strong}>{clientName || "\u2014"}</Text>
{clientAddress ? <Text>{clientAddress}</Text> : null}
{clientGstin ? <Text>GSTIN: {clientGstin}</Text> : null}
{placeOfSupplyStateCode ? <Text>Place of Supply: {placeOfSupplyStateCode}</Text> : null}
<View style={styles.half}>
<Txt style={styles.blockLabel}>Billed To</Txt>
<Txt style={styles.strong}>{clientName || "—"}</Txt>
{splitLines(clientAddress).map((line, i) => (
<Txt key={`client-${i}`}>{line}</Txt>
))}
{clientGstin ? <Txt>GSTIN: {clientGstin}</Txt> : null}
{placeOfSupplyStateCode ? <Txt>Place of Supply: {placeOfSupplyStateCode}</Txt> : null}
</View>
<View style={styles.block}>
<View style={styles.half}>
{poNumber ? (
<>
<Text style={styles.blockLabel}>PO Number</Text>
<Text style={styles.strong}>{poNumber}</Text>
<Txt style={styles.blockLabel}>PO Number</Txt>
<Txt style={styles.strong}>{poNumber}</Txt>
</>
) : null}
</View>
</View>
{/* Zero-height guard: keeps the header row from being stranded at the foot of a page. */}
<View minPresenceAhead={48} />
<View style={styles.table}>
<View style={[styles.tr, { borderBottomColor: "#161616" }]}>
<Text style={[styles.th, styles.colDesc]}>Description</Text>
<Text style={[styles.th, styles.colRate]}>Unit Price</Text>
<Text style={[styles.th, styles.colQty]}>Qty</Text>
<Text style={[styles.th, styles.colTotal]}>Total</Text>
<View style={[styles.tr, styles.trHead]} fixed>
<Txt style={[styles.th, styles.colDesc]}>Description</Txt>
<Txt style={[styles.th, styles.colRate]}>Unit Price</Txt>
<Txt style={[styles.th, styles.colQty]}>Qty</Txt>
<Txt style={[styles.th, styles.colTotal]}>Total</Txt>
</View>
{items.map((item, i) => (
<View style={styles.tr} key={item.id ?? i}>
<Text style={styles.colDesc}>{item.description || "\u2014"}</Text>
<Text style={styles.colRate}>
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "\u2014"}
</Text>
<Text style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Text>
<Text style={styles.colTotal}>{money(computeLineAmount(item))}</Text>
<View style={styles.tr} key={item.id ?? i} wrap={false}>
<View style={styles.colDesc}>
{(splitLines(item.description).length ? splitLines(item.description) : ["—"]).map(
(line, li) => (
<Txt key={`desc-${li}`}>{line}</Txt>
),
)}
</View>
<Txt style={styles.colRate}>
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
</Txt>
<Txt style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Txt>
<Txt style={styles.colTotal}>{money(computeLineAmount(item))}</Txt>
</View>
))}
</View>
<View style={styles.totalsWrap}>
<View style={styles.totalsWrap} wrap={false}>
<View style={styles.totals}>
<View style={styles.totalsRow}>
<Text>Subtotal</Text>
<Text>{money(subtotal)}</Text>
<Txt>Subtotal</Txt>
<Txt>{money(subtotal)}</Txt>
</View>
{discount > 0 ? (
<View style={styles.totalsRow}>
<Text>Discount</Text>
<Text>- {money(discount)}</Text>
<Txt>Discount</Txt>
<Txt>- {money(discount)}</Txt>
</View>
) : null}
{taxType === "cgst_sgst" ? (
<>
<View style={styles.totalsRow}>
<Text>CGST ({(taxRate / 2).toFixed(2)}%)</Text>
<Text>{money(cgst)}</Text>
<Txt>CGST ({(taxRate / 2).toFixed(2)}%)</Txt>
<Txt>{money(cgst)}</Txt>
</View>
<View style={styles.totalsRow}>
<Text>SGST ({(taxRate / 2).toFixed(2)}%)</Text>
<Text>{money(sgst)}</Text>
<Txt>SGST ({(taxRate / 2).toFixed(2)}%)</Txt>
<Txt>{money(sgst)}</Txt>
</View>
</>
) : null}
{taxType === "igst" ? (
<View style={styles.totalsRow}>
<Text>IGST ({taxRate.toFixed(2)}%)</Text>
<Text>{money(igst)}</Text>
<Txt>IGST ({taxRate.toFixed(2)}%)</Txt>
<Txt>{money(igst)}</Txt>
</View>
) : null}
<View style={styles.grand}>
<Text>Total</Text>
<Text>{money(total)}</Text>
<Txt style={styles.grandText}>Total</Txt>
<Txt style={styles.grandText}>{money(total)}</Txt>
</View>
</View>
</View>
<Text style={styles.words}>Amount in words: {amountInWords}</Text>
<Txt style={styles.words} wrap={false}>
Amount in words: {amountInWords}
</Txt>
{notes ? (
<View style={{ marginTop: 14 }}>
<Text style={styles.blockLabel}>Notes</Text>
<Text>{notes}</Text>
<View style={styles.notes} wrap={false}>
<Txt style={styles.blockLabel}>Notes</Txt>
{splitLines(notes).map((line, i) => (
<Txt key={`note-${i}`}>{line}</Txt>
))}
</View>
) : null}
<View style={styles.bottom}>
<View style={[styles.block, { maxWidth: "55%" }]}>
<View style={styles.bottom} wrap={false}>
<View style={styles.bank}>
{bank ? (
<>
<Text style={styles.blockLabel}>Pay To</Text>
<Text style={styles.strong}>{bank.accountName || vendor.vendorName}</Text>
<Text>{bank.bankName}</Text>
{bank.accountNo ? <Text>Account No.: {bank.accountNo}</Text> : null}
{bank.branch ? <Text>Branch: {bank.branch}</Text> : null}
{bank.ifsc ? <Text>IFSC: {bank.ifsc}</Text> : null}
<Txt style={styles.blockLabel}>Pay To</Txt>
<Txt style={styles.strong}>{bank.accountName || vendor.vendorName}</Txt>
<Txt>{bank.bankName}</Txt>
{bank.accountNo ? <Txt>Account No.: {bank.accountNo}</Txt> : null}
{bank.branch ? <Txt>Branch: {bank.branch}</Txt> : null}
{bank.ifsc ? <Txt>IFSC: {bank.ifsc}</Txt> : null}
</>
) : null}
</View>
@@ -301,22 +423,26 @@ export function InvoiceDocument(props: InvoicePdfProps) {
{signatureDataUri ? (
<>
<Image style={styles.signatureImg} src={signatureDataUri} />
<Text style={styles.signatureName}>{vendor.vendorName}</Text>
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
</>
) : (
<>
<Text style={styles.signatureName}>{vendor.vendorName}</Text>
<Text style={styles.fineprint}>
Computer-generated invoice; no signature is required.
</Text>
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
<Txt style={styles.fineprint}>Computer-generated invoice; no signature is required.</Txt>
</>
)}
</View>
</View>
<View style={styles.footer} fixed>
<Text>{vendor.vendorName}</Text>
<Text>{"Generated with Voiced \u2014 a Bongbetic product"}</Text>
<Text style={styles.footerLeft}>{vendor.vendorName}</Text>
<Text style={styles.footerCenter}>{"Generated with Voiced — a Bongbetic product"}</Text>
<Text
style={styles.footerRight}
render={({ pageNumber, totalPages }) =>
totalPages ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`
}
/>
</View>
</Page>
</Document>
+38
View File
@@ -0,0 +1,38 @@
import { Font } from "@react-pdf/renderer";
let registered = false;
/**
* 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). Use static TTF or WOFF files only: react-pdf embeds WOFF2 glyphs without outlines, which
* prints invisible text, and variable fonts embed only their default instance.
*/
export function registerPdfFonts(base = "/fonts"): void {
if (registered) return;
registered = true;
Font.register({
family: "IBM Plex Sans",
fonts: [
{ src: `${base}/IBMPlexSans-Light.woff`, fontWeight: 300 },
{ src: `${base}/IBMPlexSans-Regular.woff`, fontWeight: 400 },
{ src: `${base}/IBMPlexSans-Italic.woff`, fontWeight: 400, fontStyle: "italic" },
{ src: `${base}/IBMPlexSans-Medium.woff`, fontWeight: 500 },
{ src: `${base}/IBMPlexSans-SemiBold.woff`, fontWeight: 600 },
{ src: `${base}/IBMPlexSans-Bold.woff`, fontWeight: 700 },
],
});
Font.register({
family: "IBM Plex Mono",
fonts: [
{ src: `${base}/IBMPlexMono-Regular.woff`, fontWeight: 400 },
{ src: `${base}/IBMPlexMono-SemiBold.woff`, fontWeight: 600 },
],
});
// The default hyphenation inserts "-" into e-mail addresses, URLs and identifiers.
Font.registerHyphenationCallback((word) => [word]);
}
+82
View File
@@ -0,0 +1,82 @@
import { renderToBuffer } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import type { ReactElement } from "react";
export interface TextBox {
str: string;
x0: number;
x1: number;
/** Distance from the top edge of the page, in points. */
top: number;
bottom: number;
}
export interface PageText {
width: number;
height: number;
items: TextBox[];
}
export interface RenderedPdf {
pages: PageText[];
/** Raw PDF bytes as a latin1 string, for grepping object dictionaries. */
raw: string;
bytes: Uint8Array;
}
/** Renders a react-pdf element under Node and returns the text boxes pdf.js finds on each page. */
export async function renderPdfText(element: ReactElement): Promise<RenderedPdf> {
// The renderToBuffer type wants a <Document> element; any element that renders one works.
const buf = await renderToBuffer(element as Parameters<typeof renderToBuffer>[0]);
const raw = buf.toString("latin1");
const bytes = new Uint8Array(buf);
const task = getDocument({
data: new Uint8Array(buf),
useSystemFonts: false,
disableFontFace: true,
});
const doc = await task.promise;
const pages: PageText[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
const [, , width, height] = page.view;
const content = await page.getTextContent();
const items: TextBox[] = [];
for (const item of content.items) {
if (!("str" in item) || item.str.trim() === "") continue;
const x = item.transform[4];
const f = item.transform[5];
items.push({
str: item.str,
x0: x,
x1: x + item.width,
top: height - f - item.height,
bottom: height - f,
});
}
pages.push({ width, height, items });
}
await task.destroy();
return { pages, raw, bytes };
}
/** Pairs of items on one page whose boxes overlap (horizontal tolerance 0.5pt, vertical overlap must exceed 2pt). */
export function findOverlaps(page: PageText): Array<[TextBox, TextBox]> {
const out: Array<[TextBox, TextBox]> = [];
const its = page.items;
for (let i = 0; i < its.length; i++) {
for (let j = i + 1; j < its.length; j++) {
const a = its[i];
const b = its[j];
const h = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
const v = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (h > 0.5 && v > 2) out.push([a, b]);
}
}
return out;
}
export const describeBox = (t: TextBox) =>
`"${t.str}" [x ${t.x0.toFixed(1)}..${t.x1.toFixed(1)}, y ${t.top.toFixed(1)}..${t.bottom.toFixed(1)}]`;
+10 -4
View File
@@ -201,10 +201,10 @@ body {
}
.inv-top {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
align-items: flex-start;
align-items: start;
}
.inv-vendor {
@@ -214,6 +214,11 @@ body {
min-width: 0;
}
.inv-vendor > div {
flex: 1 1 0;
min-width: 0;
}
.inv-vendor__logo {
max-height: 56px;
max-width: 220px;
@@ -229,11 +234,12 @@ body {
.inv-vendor__line {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.inv-meta {
text-align: right;
min-width: 220px;
min-width: 0;
}
.inv-meta__title {