mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
/**
|
|
* 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(/<br\s*\/?>/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 <table> + 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(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
|
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
|
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\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*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
|
|
)) {
|
|
tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
|
|
}
|
|
|
|
const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
|
|
const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
|
|
const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
|
|
const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
|
|
[...tr[1].matchAll(/<td[^>]*>([\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");
|
|
}
|