mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
GET billing/invoices/:id/document?format=thermal renders a dedicated 80mm receipt template (72mm printable, 4mm margins each side), not a CSS variant of the A4 layout — the A4 CSS is absolutely-positioned/fixed-px, tuned for a 210mm page, and doesn't reflow at thermal width. No seal (not a thermal convention, renders badly on 1-bit thermal heads); line items stack (description, then qty x rate = amount) instead of a table, since a real table leaves ~10-14 chars for description at this width. PdfRenderService gains a thermal render path: full 80mm-width viewport, content height measured via page.evaluate after settle (continuous-roll receipts have no fixed page length), and a noFallback option — a Chromium failure throws a clear error instead of silently degrading to the generic A4/no-QR fallback, which would hand back a different document than what was asked for. The frontend surfaces that as a toast pointing at the existing A4 download. format is strictly validated (a4|thermal only, BadRequestException otherwise), not silently coerced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
229 lines
9.6 KiB
TypeScript
229 lines
9.6 KiB
TypeScript
import { existsSync } from "fs";
|
|
|
|
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
|
|
|
const MIN_VALID_PDF_BYTES = 2_000;
|
|
|
|
const PDF_PRINT_STYLES = `
|
|
<style id="edr-pdf-print-fix">
|
|
@media print {
|
|
html, body {
|
|
background: #fff !important;
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
}
|
|
</style>`;
|
|
|
|
/**
|
|
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
|
|
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
|
|
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
|
|
* driver expects) with the safe area carved out by margin, not by shrinking the page.
|
|
*/
|
|
const THERMAL_PAGE_WIDTH_MM = 80;
|
|
const THERMAL_MARGIN_MM = 4;
|
|
/** Extra length past the measured content, so the cut isn't flush against the last line. */
|
|
const THERMAL_FEED_MM = 6;
|
|
/** Guard against a runaway line-item list producing an absurd page. */
|
|
const THERMAL_MAX_HEIGHT_MM = 1500;
|
|
|
|
export interface PdfRenderOptions {
|
|
/** Label used in logs to identify the document kind. */
|
|
label?: string;
|
|
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
|
landscape?: boolean;
|
|
/**
|
|
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
|
|
* measured and the page height grows to fit it, rather than a fixed page with the format's
|
|
* `format: "A4"`.
|
|
*/
|
|
thermal?: boolean;
|
|
/**
|
|
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
|
|
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
|
|
* output" (it silently hands back a different document shape than what was asked for); the
|
|
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
|
|
* also supplied — an explicit fallback always wins.
|
|
*/
|
|
noFallback?: boolean;
|
|
/**
|
|
* Degraded renderer used when Chromium is unavailable. Receives the
|
|
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
|
* header). When omitted (and `noFallback` is not set), a generic single-page fallback is
|
|
* produced.
|
|
*/
|
|
fallback?: (preparedHtml: string) => Buffer;
|
|
}
|
|
|
|
/**
|
|
* Generic HTML → PDF renderer shared by every document producer (invoices,
|
|
* receipts, warehouse release orders). Renders via headless Chromium when
|
|
* available and degrades to a caller-supplied (or generic) hand-built PDF
|
|
* otherwise. This is pure infrastructure — it knows nothing about invoices.
|
|
*/
|
|
@Injectable()
|
|
export class PdfRenderService {
|
|
private readonly logger = new Logger(PdfRenderService.name);
|
|
|
|
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
|
|
const label = opts.label ?? "document";
|
|
const preparedHtml = this.injectPdfPrintStyles(html);
|
|
const executablePath = this.resolveExecutablePath();
|
|
|
|
try {
|
|
const puppeteer = await import("puppeteer");
|
|
const launchOptions: import("puppeteer").LaunchOptions = {
|
|
headless: true,
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
|
...(executablePath ? { executablePath } : {}),
|
|
};
|
|
|
|
const browser = await puppeteer.default.launch(launchOptions);
|
|
try {
|
|
const page = await browser.newPage();
|
|
const thermal = opts.thermal ?? false;
|
|
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
|
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
|
|
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
|
await page.emulateMediaType("print");
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
const pdf = thermal
|
|
? await page.pdf({
|
|
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
|
|
height: `${await this.thermalContentHeightMm(page)}mm`,
|
|
printBackground: true,
|
|
margin: {
|
|
top: `${THERMAL_MARGIN_MM}mm`,
|
|
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
|
|
left: `${THERMAL_MARGIN_MM}mm`,
|
|
right: `${THERMAL_MARGIN_MM}mm`,
|
|
},
|
|
})
|
|
: await page.pdf({
|
|
format: "A4",
|
|
landscape: opts.landscape ?? false,
|
|
printBackground: true,
|
|
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
|
});
|
|
|
|
const buffer = Buffer.from(pdf);
|
|
if (!this.isValidPdf(buffer)) {
|
|
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
|
|
}
|
|
this.logger.log(
|
|
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
|
|
);
|
|
return buffer;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
|
if (!opts.fallback && opts.noFallback) {
|
|
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
|
// printer output" — it silently hands back a different document than what was asked for.
|
|
// Fail loudly instead; the caller already has a working A4 download to fall back to.
|
|
throw new InternalServerErrorException(
|
|
`${label} could not be generated — thermal rendering requires Chromium. ` +
|
|
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
|
|
);
|
|
}
|
|
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
|
if (this.isValidPdf(fallback)) {
|
|
this.logger.warn(
|
|
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
|
);
|
|
return fallback;
|
|
}
|
|
throw new InternalServerErrorException(
|
|
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
|
|
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
|
|
* receipt, not a fixed A4-length page with blank space at the bottom.
|
|
*/
|
|
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
|
|
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
|
|
// isn't a known global to type-check against — the string is evaluated in the page's own
|
|
// browser context regardless, same as the closure form would be.
|
|
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
|
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
|
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
|
|
}
|
|
|
|
private injectPdfPrintStyles(html: string): string {
|
|
if (html.includes("edr-pdf-print-fix")) return html;
|
|
if (html.includes("</head>")) {
|
|
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
|
|
}
|
|
return `${PDF_PRINT_STYLES}${html}`;
|
|
}
|
|
|
|
private resolveExecutablePath(): string | undefined {
|
|
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
|
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
|
|
const candidates = [
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/google-chrome",
|
|
];
|
|
return candidates.find((path) => existsSync(path));
|
|
}
|
|
|
|
isValidPdf(buffer: Buffer): boolean {
|
|
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
|
}
|
|
|
|
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
|
|
private genericFallbackPdf(html: string): Buffer {
|
|
const text = html
|
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/ /gi, " ")
|
|
.replace(/&/gi, "&")
|
|
.replace(/</gi, "<")
|
|
.replace(/>/gi, ">")
|
|
.replace(/[^\x20-\x7e]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 900);
|
|
|
|
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
|
|
const stream =
|
|
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
|
|
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
|
|
"ET";
|
|
|
|
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 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
|
];
|
|
|
|
let pdf = "%PDF-1.4\n";
|
|
const offsets: number[] = [];
|
|
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 += "% pad\n";
|
|
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
|
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
for (const offset of offsets) 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");
|
|
}
|
|
}
|