mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
fix(billing): fallback PDF paginates long tables instead of truncating
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>
This commit is contained in:
@@ -170,15 +170,20 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
|||||||
// whole HTML at once would merge both copies' tiles and drop the watermarks.
|
// 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 copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
|
||||||
const fragments = copies.length ? copies : [html];
|
const fragments = copies.length ? copies : [html];
|
||||||
return assemblePdf(fragments.map((fragment) => buildTabularPageOps(fragment)));
|
return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTabularPageOps(html: string): { ops: string[]; page: { width: number; height: number } } {
|
function buildTabularPageOps(
|
||||||
|
html: string,
|
||||||
|
): Array<{ ops: string[]; page: { width: number; height: number } }> {
|
||||||
const pick = (re: RegExp) => html.match(re)?.[1];
|
const pick = (re: RegExp) => html.match(re)?.[1];
|
||||||
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
||||||
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||||
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/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 generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
|
||||||
|
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||||
|
|
||||||
const tiles: Array<[string, string]> = [];
|
const tiles: Array<[string, string]> = [];
|
||||||
for (const m of html.matchAll(
|
for (const m of html.matchAll(
|
||||||
@@ -204,30 +209,49 @@ function buildTabularPageOps(html: string): { ops: string[]; page: { width: numb
|
|||||||
const M = 32;
|
const M = 32;
|
||||||
const contentW = page.width - M * 2;
|
const contentW = page.width - M * 2;
|
||||||
const right = page.width - M;
|
const right = page.width - M;
|
||||||
const ops: string[] = [];
|
const MAX_PAGES = 12;
|
||||||
|
|
||||||
// Copy watermark, underneath everything else.
|
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
|
||||||
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
let ops: string[] = [];
|
||||||
if (watermark) ops.push(watermarkOp(watermark, page));
|
let y = 0;
|
||||||
|
|
||||||
// Header
|
const drawFullHeader = () => {
|
||||||
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
|
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("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));
|
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 (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
|
||||||
const metaLabel =
|
if (metaRef) {
|
||||||
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
|
ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
|
||||||
if (metaRef) {
|
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
|
||||||
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));
|
||||||
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;
|
||||||
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
|
};
|
||||||
|
|
||||||
// Summary tiles
|
const drawContinuationHeader = (pageNo: number) => {
|
||||||
let y = page.height - 100;
|
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) {
|
if (tiles.length) {
|
||||||
const cols = landscape ? 6 : 4;
|
const cols = landscape ? 6 : 4;
|
||||||
const tileW = contentW / cols;
|
const tileW = contentW / cols;
|
||||||
@@ -243,21 +267,34 @@ function buildTabularPageOps(html: string): { ops: string[]; page: { width: numb
|
|||||||
y -= tileH + 12;
|
y -= tileH + 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table
|
// Table, paginated across as many pages as the rows need.
|
||||||
if (headers.length) {
|
if (headers.length) {
|
||||||
const colW = contentW / headers.length;
|
const colW = contentW / headers.length;
|
||||||
const headerH = 16;
|
const headerH = 16;
|
||||||
const rowH = 14;
|
const rowH = 14;
|
||||||
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
||||||
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
const bottomReserve = 46; // keep clear of the page edge on row-only pages
|
||||||
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;
|
const drawTableHeader = () => {
|
||||||
for (const row of rows) {
|
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
||||||
if (y < 96) break;
|
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));
|
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
|
||||||
headers.forEach((_h, c) => {
|
headers.forEach((_h, c) => {
|
||||||
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
|
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
|
||||||
@@ -265,30 +302,33 @@ function buildTabularPageOps(html: string): { ops: string[]; page: { width: numb
|
|||||||
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
|
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
|
||||||
});
|
});
|
||||||
y -= rowH;
|
y -= rowH;
|
||||||
shown += 1;
|
|
||||||
}
|
}
|
||||||
if (shown < rows.length) {
|
if (truncated > 0) {
|
||||||
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notice (verification clause)
|
// 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) {
|
if (notice) {
|
||||||
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
|
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
|
||||||
wrapText(notice, landscape ? 155 : 104)
|
wrapText(notice, landscape ? 155 : 104)
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
|
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signatures
|
|
||||||
const sigW = contentW / signatures.length;
|
const sigW = contentW / signatures.length;
|
||||||
signatures.forEach((s, i) => {
|
signatures.forEach((sig, i) => {
|
||||||
const x = M + i * sigW;
|
const x = M + i * sigW;
|
||||||
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
|
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));
|
ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
|
||||||
});
|
});
|
||||||
|
finishPage();
|
||||||
|
|
||||||
return { ops, page };
|
return pagesOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Greedy word-wrap to a maximum character width. */
|
/** Greedy word-wrap to a maximum character width. */
|
||||||
|
|||||||
Reference in New Issue
Block a user