import { existsSync } from "fs"; import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; const MIN_VALID_PDF_BYTES = 2_000; const PDF_PRINT_STYLES = ` `; /** * 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 { 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 { // 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("")) { return html.replace("", `${PDF_PRINT_STYLES}`); } 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(//gi, "") .replace(//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"); } }