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:
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user