/**
* 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
| ]*>([\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[] = []; 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"); } |