97 lines
3.9 KiB
TypeScript
97 lines
3.9 KiB
TypeScript
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<T>(work: () => Promise<T>): Promise<T> {
|
|
if (this.active >= this.config.get('HTML_PDF_MAX_CONCURRENCY')) await new Promise<void>((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<void> {
|
|
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<Buffer> {
|
|
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 });
|
|
}
|
|
});
|
|
}
|
|
}
|