import { existsSync } from 'fs'; import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; const MIN_VALID_PDF_BYTES = 2_000; const PDF_PRINT_STYLES = ` `; @Injectable() export class ContractPdfService { private readonly logger = new Logger(ContractPdfService.name); async htmlToPdfBuffer(html: string): Promise { 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(); await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000, }); await page.emulateMediaType('print'); await new Promise((resolve) => setTimeout(resolve, 400)); const pdf = await page.pdf({ format: 'A4', printBackground: true, displayHeaderFooter: true, headerTemplate: '', footerTemplate: '
Page of
', margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' }, }); const buffer = Buffer.from(pdf); if (!this.isValidPdf(buffer)) { throw new Error( `Puppeteer produced invalid PDF (${buffer.length} bytes)`, ); } this.logger.log( `Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, ); return buffer; } finally { await browser.close(); } } catch (err) { this.logger.error( `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, ); const fallback = this.htmlToBasicPdfBuffer(preparedHtml); if (this.isValidPdf(fallback)) { this.logger.warn( `Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, ); return fallback; } throw new InternalServerErrorException( 'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', ); } } private injectPdfPrintStyles(html: string): string { if (html.includes('contract-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((p) => existsSync(p)); } private isValidPdf(buffer: Buffer): boolean { return ( buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-' ); } private htmlToBasicPdfBuffer(html: string): Buffer { const text = this.htmlToPlainText(html); const lines = this.wrapLines(text, 92).slice(0, 72); const body = lines .map((line, index) => { const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; return `${prefix} (${this.escapePdfText(line)}) Tj`; }) .join('\n'); const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; 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[] = [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'); } private htmlToPlainText(html: string): string { return html .replace(//gi, '') .replace(//gi, '') .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') .replace(//gi, '\n') .replace(/<[^>]+>/g, ' ') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/g, "'") .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-') .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) .filter(Boolean) .join('\n'); } private wrapLines(text: string, width: number): string[] { const wrapped: string[] = []; for (const rawLine of text.split('\n')) { const words = rawLine.split(' '); let line = ''; for (const word of words) { const next = line ? `${line} ${word}` : word; if (next.length > width && line) { wrapped.push(line); line = word; } else { line = next; } } if (line) wrapped.push(line); } return wrapped.length ? wrapped : ['Document']; } private escapePdfText(value: string): string { return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); } }