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