import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { AppConfigService } from '../config/app-config.service.js'; /** Dónde suele estar Chrome o Chromium (macOS, Linux y Windows). */ const CANDIDATES = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', ]; /** * Convierte el HTML de un reporte en PDF con Chrome sin pantalla (`--print-to-pdf`): el mismo motor que imprime el navegador, * pero con el tamaño de papel y los márgenes de la plantilla (`@page`) fijos, sin depender del cuadro de impresión de cada * usuario. No descarga nada: usa el Chrome o Chromium instalado (`CHROME_BIN`). Cola propia (`HTML_PDF_MAX_CONCURRENCY`). */ @Injectable() export class HtmlPdfService { private readonly logger = new Logger(HtmlPdfService.name); private active = 0; private readonly waiting: (() => void)[] = []; constructor(private readonly config: AppConfigService) {} /** Ruta de Chrome: la de `CHROME_BIN` o la primera instalada que se encuentre. */ chrome(): string | null { const wanted = this.config.get('CHROME_BIN'); if (wanted) return existsSync(wanted) ? wanted : null; return CANDIDATES.find((c) => existsSync(c)) ?? null; } private async turn(work: () => Promise): Promise { if (this.active >= this.config.get('HTML_PDF_MAX_CONCURRENCY')) await new Promise((go) => this.waiting.push(go)); this.active++; try { return await work(); } finally { this.active--; this.waiting.shift()?.(); } } /** Ejecuta Chrome (separado para poder simularlo en las pruebas). */ protected run(bin: string, args: string[], timeoutMs: number): Promise { return new Promise((done, fail) => { execFile(bin, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, _stdout, stderr) => { if (err) fail(new Error(`Chrome no pudo generar el PDF: ${(stderr || err.message).toString().slice(0, 300)}`)); else done(); }); }); } async toPdf(html: string): Promise { const bin = this.chrome(); if (!bin) throw new ServiceUnavailableException('No hay Chrome o Chromium instalado para generar el PDF (indique su ruta en CHROME_BIN)'); return this.turn(async () => { const dir = await mkdtemp(join(tmpdir(), 'reporte-')); try { const input = join(dir, 'reporte.html'); const output = join(dir, 'reporte.pdf'); await writeFile(input, html, 'utf8'); const args = [ '--headless=new', '--disable-gpu', '--no-pdf-header-footer', '--disable-dev-shm-usage', `--user-data-dir=${join(dir, 'perfil')}`, // como administrador (contenedores) Chrome exige desactivar su sandbox ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []), `--print-to-pdf=${output}`, pathToFileURL(input).href, ]; await this.run(bin, args, this.config.get('HTML_PDF_TIMEOUT') * 1000); const pdf = await readFile(output); if (pdf.subarray(0, 4).toString('latin1') !== '%PDF') throw new Error('Chrome no devolvió un PDF'); return pdf; } catch (e) { this.logger.error(`No se pudo generar el PDF: ${e instanceof Error ? e.message : String(e)}`); throw e; } finally { await rm(dir, { recursive: true, force: true }); } }); } }