mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 16:00:56 +00:00
The marshalling manifest cut off at one page ("... 10 more row(s) not
shown") because the fallback drew rows only until it hit the bottom band.
Now the table flows across as many pages as it needs:
- page 1 keeps the full header + summary tiles; continuation pages get a slim
"(continued — page N)" header and a re-drawn table header
- the verification notice and signature lines stay pinned to the final page,
moving to a fresh page when rows run too deep for the bottom band
- the copy watermark repeats on every page of its copy
- a 12-page safety cap keeps the old truncation note as a last resort
Verified by standalone render: 50-row manifest -> 2 pages, all 50 rows, no
truncation; 5-row doc stays 1 page; two-copy freight order still renders 2
watermarked pages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
431 lines
16 KiB
TypeScript
431 lines
16 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();
|
|
}
|
|
|
|
/**
|
|
* Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
|
|
* drawn FIRST so the page content sits on top of it. 30-degree rotation via a
|
|
* text matrix; roughly centered on the page.
|
|
*/
|
|
export function watermarkOp(text: string, page: { width: number; height: number }): string {
|
|
const label = clipText(text, 46);
|
|
const size = 34;
|
|
const w = textWidth(label, size);
|
|
const x = page.width / 2 - (w * 0.866) / 2;
|
|
const y = page.height / 2 - (w * 0.5) / 2;
|
|
return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Documents printed in duplicate wrap each copy in <section class="copy">
|
|
// (freight order: Port Operations copy + Gate Security copy). Render one
|
|
// page per copy, each with its own watermark and tile set — parsing the
|
|
// whole HTML at once would merge both copies' tiles and drop the watermarks.
|
|
const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
|
|
const fragments = copies.length ? copies : [html];
|
|
return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
|
|
}
|
|
|
|
function buildTabularPageOps(
|
|
html: string,
|
|
): Array<{ ops: string[]; page: { width: number; height: number } }> {
|
|
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 metaLabel =
|
|
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
|
|
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
|
|
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/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 MAX_PAGES = 12;
|
|
|
|
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
|
|
let ops: string[] = [];
|
|
let y = 0;
|
|
|
|
const drawFullHeader = () => {
|
|
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(clipText(metaLabel, 26), 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));
|
|
y = page.height - 100;
|
|
};
|
|
|
|
const drawContinuationHeader = (pageNo: number) => {
|
|
ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
|
|
ops.push(
|
|
textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
|
|
);
|
|
if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
|
|
y = page.height - 54;
|
|
};
|
|
|
|
const startPage = (first: boolean) => {
|
|
ops = [];
|
|
if (watermark) ops.push(watermarkOp(watermark, page));
|
|
if (first) drawFullHeader();
|
|
else drawContinuationHeader(pagesOut.length + 1);
|
|
};
|
|
|
|
const finishPage = () => pagesOut.push({ ops, page });
|
|
|
|
startPage(true);
|
|
|
|
// Summary tiles (first page only)
|
|
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, paginated across as many pages as the rows need.
|
|
if (headers.length) {
|
|
const colW = contentW / headers.length;
|
|
const headerH = 16;
|
|
const rowH = 14;
|
|
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
|
const bottomReserve = 46; // keep clear of the page edge on row-only pages
|
|
|
|
const drawTableHeader = () => {
|
|
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;
|
|
};
|
|
|
|
drawTableHeader();
|
|
let truncated = 0;
|
|
for (const [index, row] of rows.entries()) {
|
|
if (y - rowH < bottomReserve) {
|
|
if (pagesOut.length + 1 >= MAX_PAGES) {
|
|
truncated = rows.length - index;
|
|
break;
|
|
}
|
|
finishPage();
|
|
startPage(false);
|
|
drawTableHeader();
|
|
}
|
|
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;
|
|
}
|
|
if (truncated > 0) {
|
|
ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
|
}
|
|
}
|
|
|
|
// Notice + signatures live on the final page; give them a fresh page when the
|
|
// rows ran too deep for the fixed bottom band.
|
|
if (y < 110 && (notice || signatures.length)) {
|
|
finishPage();
|
|
startPage(false);
|
|
}
|
|
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)));
|
|
}
|
|
const sigW = contentW / signatures.length;
|
|
signatures.forEach((sig, i) => {
|
|
const x = M + i * sigW;
|
|
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
|
|
ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
|
|
});
|
|
finishPage();
|
|
|
|
return pagesOut;
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
|
|
/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
|
|
export function assemblePdf(
|
|
pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
|
|
): Buffer {
|
|
const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
|
|
const objects: string[] = [
|
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
|
|
];
|
|
for (const [i, p] of pages.entries()) {
|
|
const stream = p.ops.join("\n");
|
|
objects.push(
|
|
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
|
|
);
|
|
objects.push(`<< /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");
|
|
}
|