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 + notice + signature lines" document (the + * marshalling / load-list layout the train-scheduling builders emit) and draw it as a + * styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real + * document, not a flat text dump. Switches to landscape when the table is wide. + */ +export function buildTabularFallbackPdf(html: string): Buffer { + 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) ?? ""); + const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\s\S]*?)<\/strong>/i) ?? ""); + const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? ""); + + const tiles: Array<[string, string]> = []; + for (const m of html.matchAll( + /class="tile"[^>]*>\s*([\s\S]*?)<\/span>\s*([\s\S]*?)<\/strong>/gi, + )) { + tiles.push([htmlToText(m[1]), htmlToText(m[2])]); + } + + const thead = pick(/([\s\S]*?)<\/thead>/i) ?? ""; + const headers = [...thead.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1])); + const tbody = pick(/([\s\S]*?)<\/tbody>/i) ?? ""; + const rows: string[][] = [...tbody.matchAll(/]*>([\s\S]*?)<\/tr>/gi)].map((tr) => + [...tr[1].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 { + return this.pdf.htmlToPdfBuffer(html, { + label, + fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml), + }); + } + /** * Render document HTML with a STYLED hand-built fallback (the release layout, * but with a custom title + section heading) for when Chromium is unavailable.