From 4ca436348919d957af0c8ad3fcf16a1b2fe44d1c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:56:20 +0000 Subject: [PATCH] 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
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) --- .../billing/documents/styled-pdf.util.ts | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index b4f168e4e..f75a284a4 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -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 + 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
+ // (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(/
([\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(/]*>([\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]*?)>\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"); +}