mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(billing): fallback PDF renders duplicate-copy documents as watermarked pages
buildTabularFallbackPdf parsed the whole HTML at once, so a two-copy document
(freight order: Port Operations copy + Gate Security & Carrier copy) came out
as ONE page with every tile duplicated and no watermarks.
- documents wrapped in <section class="copy"> now render one page per copy,
each parsed independently (no more merged/duplicated tiles)
- each page carries its copy label as a large rotated light-gray watermark,
drawn beneath the content (new watermarkOp, 30-degree text matrix)
- new assemblePdf() multi-page assembler; assembleSinglePagePdf untouched for
its existing callers
- the meta label is parsed from the document ("Booking") instead of the
hardcoded "TRAIN / SCHEDULE"
Verified by compiling the util standalone and rendering a two-copy freight
order: 2 pages, both watermarks present, tiles once per page, valid xref/EOF;
copy-less documents (marshalling) still render a single unwatermarked page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
|
||||
.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
|
||||
@@ -150,6 +164,16 @@ export function htmlToText(html: string): string {
|
||||
* 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.map((fragment) => buildTabularPageOps(fragment)));
|
||||
}
|
||||
|
||||
function buildTabularPageOps(html: string): { 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) ?? "");
|
||||
@@ -182,13 +206,19 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
const right = page.width - M;
|
||||
const ops: string[] = [];
|
||||
|
||||
// Copy watermark, underneath everything else.
|
||||
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||
if (watermark) ops.push(watermarkOp(watermark, page));
|
||||
|
||||
// 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));
|
||||
const metaLabel =
|
||||
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
|
||||
if (metaRef) {
|
||||
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
|
||||
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) {
|
||||
@@ -258,7 +288,7 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
|
||||
});
|
||||
|
||||
return assembleSinglePagePdf(ops, page);
|
||||
return { ops, page };
|
||||
}
|
||||
|
||||
/** Greedy word-wrap to a maximum character width. */
|
||||
@@ -320,3 +350,41 @@ export function assembleSinglePagePdf(
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user