mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
205 lines
6.7 KiB
TypeScript
205 lines
6.7 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="contract-pdf-print-fix">
|
|
@media print {
|
|
html, body {
|
|
background: #fff !important;
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
.cover {
|
|
min-height: auto !important;
|
|
page-break-after: always;
|
|
}
|
|
.cover-title {
|
|
margin: 24mm 0 20mm !important;
|
|
}
|
|
}
|
|
</style>`;
|
|
|
|
@Injectable()
|
|
export class ContractPdfService {
|
|
private readonly logger = new Logger(ContractPdfService.name);
|
|
|
|
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
|
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: '<span></span>',
|
|
footerTemplate:
|
|
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
|
|
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('</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((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(/<script[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
|
|
.replace(/<br\s*\/?>/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, '\\)');
|
|
}
|
|
}
|