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.
83 lines
2.6 KiB
TypeScript
83 lines
2.6 KiB
TypeScript
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)}]`;
|