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:
Hagernesh
2026-07-10 10:05:00 +00:00
parent 4ca4363489
commit 00875dd2f4

View File

@@ -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. */