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 8eb90172e..b4f168e4e 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
@@ -121,6 +121,146 @@ export function sealOp(
return ops.join("\n");
}
+/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
+export function clipText(value: string, max: number): string {
+ const t = String(value ?? "");
+ return t.length > max ? t.slice(0, Math.max(1, max)) : t;
+}
+
+/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
+export function htmlToText(html: string): string {
+ return String(html ?? "")
+ .replace(/
/gi, " ")
+ .replace(/<[^>]+>/g, " ")
+ .replace(/&/gi, "&")
+ .replace(/</gi, "<")
+ .replace(/>/gi, ">")
+ .replace(/"/gi, '"')
+ .replace(/'/g, "'")
+ .replace(/ /gi, " ")
+ .replace(/[^\x20-\x7e]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/**
+ * Parse a "summary tiles + one
| ]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1])); + const tbody = pick(/ | ([\s\S]*?)<\/tbody>/i) ?? ""; + const rows: string[][] = [...tbody.matchAll(/
|---|
| ]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
+ );
+ const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
+ const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
+ .map((m) => htmlToText(m[1]))
+ .filter(Boolean);
+ const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
+
+ const landscape = headers.length > 7;
+ const page = landscape ? PageSize.landscape : PageSize.portrait;
+ const M = 32;
+ const contentW = page.width - M * 2;
+ const right = page.width - M;
+ const ops: string[] = [];
+
+ // 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));
+ if (metaRef) {
+ ops.push(textOpRight("TRAIN / SCHEDULE", 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) {
+ ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
+ }
+ ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
+
+ // Summary tiles
+ let y = page.height - 100;
+ if (tiles.length) {
+ const cols = landscape ? 6 : 4;
+ const tileW = contentW / cols;
+ const tileH = 32;
+ tiles.forEach(([label, value], i) => {
+ const col = i % cols;
+ if (col === 0 && i > 0) y -= tileH;
+ const x = M + col * tileW;
+ ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
+ ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
+ ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
+ });
+ y -= tileH + 12;
+ }
+
+ // Table
+ if (headers.length) {
+ const colW = contentW / headers.length;
+ const headerH = 16;
+ const rowH = 14;
+ const cellChars = Math.max(4, Math.floor(colW / 3.9));
+ ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
+ headers.forEach((h, c) =>
+ ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
+ );
+ y -= headerH;
+
+ let shown = 0;
+ for (const row of rows) {
+ if (y < 96) break;
+ ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
+ headers.forEach((_h, c) => {
+ if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
+ const cell = row[c] ?? "";
+ if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
+ });
+ y -= rowH;
+ shown += 1;
+ }
+ if (shown < rows.length) {
+ ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
+ }
+ }
+
+ // Notice (verification clause)
+ if (notice) {
+ ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
+ wrapText(notice, landscape ? 155 : 104)
+ .slice(0, 2)
+ .forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
+ }
+
+ // Signatures
+ const sigW = contentW / signatures.length;
+ signatures.forEach((s, i) => {
+ const x = M + i * sigW;
+ ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
+ ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
+ });
+
+ return assembleSinglePagePdf(ops, page);
+}
+
/** Greedy word-wrap to a maximum character width. */
export function wrapText(text: string, maxChars: number): string[] {
const out: string[] = [];
@@ -141,13 +281,22 @@ export function wrapText(text: string, maxChars: number): string[] {
return out.length ? out : [""];
}
-/** Assemble a single-page A4 PDF from content-stream ops (Helvetica fonts). */
-export function assembleSinglePagePdf(ops: string[]): Buffer {
+/** A4 page sizes in PDF points. */
+export const PageSize = {
+ portrait: { width: 595, height: 842 },
+ landscape: { width: 842, height: 595 },
+} as const;
+
+/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
+export function assembleSinglePagePdf(
+ ops: string[],
+ page: { width: number; height: number } = PageSize.portrait,
+): Buffer {
const stream = ops.join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
- "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>",
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 22dec2a37..1755a18f8 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -1625,9 +1625,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
- // Generic render — NOT the release-order fallback (would mislabel this as a
- // gate-clearance / release order when Chromium is unavailable).
- const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
+ // Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
+ // NOT the release-order fallback (would mislabel this as a gate-clearance order).
+ const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1645,8 +1645,8 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule);
- // Generic render — NOT the release-order fallback (see importLoadListDocument).
- const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
+ // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
+ const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts
index 1d8e3d9ea..1d02633d5 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
+import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
const MIN_VALID_PDF_BYTES = 2_000;
@@ -32,6 +33,19 @@ export class WarehouseReleaseDocumentService {
return this.pdf.htmlToPdfBuffer(html, { label });
}
+ /**
+ * Render a "summary tiles + one table + notice + signatures" document (the
+ * marshalling / load-list layout) with a STYLED table-aware fallback for when
+ * Chromium is unavailable — so the manifest draws as a real gridded document
+ * instead of a flat plain-text dump.
+ */
+ renderTabularDocument(html: string, label = 'Document'): Promise |