/** * Minimal hand-built PDF primitives shared by the Chromium-less document * fallbacks (invoices, receipts). These draw a genuine vector layout — boxes, * rules, right-aligned money, a round seal — so a document still looks like a * real document when headless Chromium is unavailable, instead of degrading to * a flat plain-text dump. Coordinates are PDF user space (origin bottom-left, * A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold. */ export const MIN_VALID_PDF_BYTES = 2_000; /** Colours as PDF "r g b" triples in the 0..1 range. */ export const PdfColor = { teal: "0.06 0.46 0.43", dark: "0.06 0.09 0.16", gray: "0.39 0.45 0.55", line: "0.80 0.84 0.89", shade: "0.96 0.97 0.98", tint: "0.94 0.99 0.98", } as const; export function escapePdfText(value: string): string { return value .replace(/\\/g, "\\\\") .replace(/\(/g, "\\(") .replace(/\)/g, "\\)") .replace(/[^\x20-\x7e]/g, " "); } /** Approximate rendered width of Helvetica text (slightly over-estimated so * right-aligned text never crosses its column edge). */ export function textWidth(text: string, size: number): number { return text.length * size * 0.52; } export function textOp( text: string, x: number, y: number, size: number, font: "F1" | "F2" = "F1", color: string = PdfColor.dark, ): string { return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; } /** Right-align `text` so it ends at `rightX`. */ export function textOpRight( text: string, rightX: number, y: number, size: number, font: "F1" | "F2" = "F1", color: string = PdfColor.dark, ): string { return textOp(text, rightX - textWidth(text, size), y, size, font, color); } export function lineOp( x1: number, y1: number, x2: number, y2: number, color: string = PdfColor.line, width = 0.8, ): string { return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; } export function rectOp( x: number, y: number, width: number, height: number, fillColor = "1 1 1", strokeColor: string = PdfColor.line, lineWidth = 0.7, ): string { return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`; } function circlePath(cx: number, cy: number, r: number): string { const k = 0.5522847498; const c = r * k; return [ `${cx + r} ${cy} m`, `${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`, `${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`, `${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`, `${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`, "h", ].join("\n"); } /** A double-ring round rubber-stamp seal carrying up to three centred lines. */ export function sealOp( cx: number, cy: number, r: number, lines: string[], color: string = PdfColor.teal, ): string { const rows = lines.slice(0, 3); const ops = [ "q", `${color} RG`, `${color} rg`, "2 w", circlePath(cx, cy, r), "S", "0.7 w", circlePath(cx, cy, r - 6), "S", ]; const startY = cy + (rows.length - 1) * 6; rows.forEach((text, i) => { const size = i === 0 ? 10 : 7.5; ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color)); }); ops.push("Q"); 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[] = []; for (const raw of String(text ?? "").split("\n")) { const words = raw.split(/\s+/).filter(Boolean); let line = ""; for (const word of words) { const next = line ? `${line} ${word}` : word; if (next.length > maxChars && line) { out.push(line); line = word; } else { line = next; } } if (line) out.push(line); } return out.length ? out : [""]; } /** 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 ${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`, ]; 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"); }