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
+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)}]`;