fix(billing): fallback PDF renders duplicate-copy documents as watermarked pages

buildTabularFallbackPdf parsed the whole HTML at once, so a two-copy document
(freight order: Port Operations copy + Gate Security & Carrier copy) came out
as ONE page with every tile duplicated and no watermarks.

- documents wrapped in <section class="copy"> now render one page per copy,
  each parsed independently (no more merged/duplicated tiles)
- each page carries its copy label as a large rotated light-gray watermark,
  drawn beneath the content (new watermarkOp, 30-degree text matrix)
- new assemblePdf() multi-page assembler; assembleSinglePagePdf untouched for
  its existing callers
- the meta label is parsed from the document ("Booking") instead of the
  hardcoded "TRAIN / SCHEDULE"

Verified by compiling the util standalone and rendering a two-copy freight
order: 2 pages, both watermarks present, tiles once per page, valid xref/EOF;
copy-less documents (marshalling) still render a single unwatermarked page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-10 09:56:20 +00:00
parent 707923b603
commit 4ca4363489

View File

@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
.trim();
}
/**
* Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
* drawn FIRST so the page content sits on top of it. 30-degree rotation via a
* text matrix; roughly centered on the page.
*/
export function watermarkOp(text: string, page: { width: number; height: number }): string {
const label = clipText(text, 46);
const size = 34;
const w = textWidth(label, size);
const x = page.width / 2 - (w * 0.866) / 2;
const y = page.height / 2 - (w * 0.5) / 2;
return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
}
/**
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
@@ -150,6 +164,16 @@ export function htmlToText(html: string): string {
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
// Documents printed in duplicate wrap each copy in <section class="copy">
// (freight order: Port Operations copy + Gate Security copy). Render one
// page per copy, each with its own watermark and tile set — parsing the
// whole HTML at once would merge both copies' tiles and drop the watermarks.
const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
const fragments = copies.length ? copies : [html];
return assemblePdf(fragments.map((fragment) => buildTabularPageOps(fragment)));
}
function buildTabularPageOps(html: string): { ops: string[]; page: { width: number; height: number } } {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
@@ -182,13 +206,19 @@ export function buildTabularFallbackPdf(html: string): Buffer {
const right = page.width - M;
const ops: string[] = [];
// Copy watermark, underneath everything else.
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
if (watermark) ops.push(watermarkOp(watermark, page));
// Header
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
const metaLabel =
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
@@ -258,7 +288,7 @@ export function buildTabularFallbackPdf(html: string): Buffer {
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
return assembleSinglePagePdf(ops, page);
return { ops, page };
}
/** Greedy word-wrap to a maximum character width. */
@@ -320,3 +350,41 @@ export function assembleSinglePagePdf(
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
export function assemblePdf(
pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
): Buffer {
const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
const objects: string[] = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
];
for (const [i, p] of pages.entries()) {
const stream = p.ops.join("\n");
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
);
objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
}
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}