html-to-pdf.js
1 /** 2 * HTML-to-PDF Converter 3 * 4 * Uses Playwright to render self-contained HTML into an A4 PDF. 5 * Also captures page section screenshots for the landing page montage. 6 */ 7 8 import { mkdirSync } from 'fs'; 9 import { dirname, join } from 'path'; 10 import { launchStealthBrowser, createStealthContext } from '../utils/stealth-browser.js'; 11 import Logger from '../utils/logger.js'; 12 13 const logger = new Logger('HtmlToPdf'); 14 15 /** 16 * Render an HTML string to a PDF file 17 * @param {Object} params 18 * @param {string} params.html - Complete HTML document string 19 * @param {string} params.outputPath - Path to save the PDF 20 * @returns {Promise<string>} Path to generated PDF 21 */ 22 export async function renderReportPDF({ html, outputPath }) { 23 logger.info(`Rendering PDF to ${outputPath}`); 24 mkdirSync(dirname(outputPath), { recursive: true }); 25 26 const browser = await launchStealthBrowser({ stealthLevel: 'minimal' }); 27 try { 28 const context = await createStealthContext(browser); 29 const page = await context.newPage(); 30 31 await page.setContent(html, { waitUntil: 'networkidle', timeout: 30000 }); 32 33 await page.pdf({ 34 path: outputPath, 35 format: 'A4', 36 printBackground: true, 37 margin: { top: '0', bottom: '0', left: '0', right: '0' }, 38 preferCSSPageSize: true, 39 }); 40 41 await context.close(); 42 logger.success(`PDF generated: ${outputPath}`); 43 return outputPath; 44 } finally { 45 await browser.close(); 46 } 47 } 48 49 /** 50 * Capture screenshots of specific page sections for the landing page montage. 51 * Each section is identified by its HTML id attribute. 52 * 53 * @param {Object} params 54 * @param {string} params.html - Complete HTML document string 55 * @param {string} params.outputDir - Directory to save PNG screenshots 56 * @param {Array<{id: string, name: string}>} [params.sections] - Sections to capture 57 * @returns {Promise<Array<{name: string, path: string}>>} 58 */ 59 export async function capturePageScreenshots({ 60 html, 61 outputDir, 62 sections = [ 63 { id: 'page-cover', name: 'report-cover' }, 64 { id: 'page-factors', name: 'report-factors' }, 65 { id: 'page-screenshots', name: 'report-screenshots' }, 66 { id: 'page-recommendations', name: 'report-recommendations' }, 67 ], 68 }) { 69 logger.info(`Capturing ${sections.length} page screenshots`); 70 mkdirSync(outputDir, { recursive: true }); 71 72 const browser = await launchStealthBrowser({ stealthLevel: 'minimal' }); 73 const results = []; 74 75 try { 76 const context = await createStealthContext(browser); 77 const page = await context.newPage(); 78 79 // Screenshot margin: 40px on all sides, matching the visual whitespace of the PDF 80 const margin = 40; 81 const pageWidth = 794; 82 83 // Viewport wider than A4 so the margin fits; fullPage:true reaches any y offset 84 await page.setViewportSize({ width: pageWidth + margin * 2, height: 1123 }); 85 86 // Inject a light background so the margin area shows as off-white (not transparent) 87 const htmlWithMargin = html.replace( 88 '</head>', 89 `<style> 90 body { background: #f0f0f0 !important; margin: 0; padding: ${margin}px; box-sizing: border-box; } 91 .page { margin: 0 auto; } 92 </style></head>` 93 ); 94 await page.setContent(htmlWithMargin, { waitUntil: 'networkidle', timeout: 30000 }); 95 96 const captureWithMargins = async (id, outputPath) => { 97 const el = await page.$(`#${id}`); 98 if (!el) return false; 99 const box = await el.boundingBox(); 100 if (!box) return false; 101 // Clip includes the full injected margin on all sides 102 const y = Math.max(0, box.y - margin); 103 await page.screenshot({ 104 path: outputPath, 105 type: 'png', 106 fullPage: true, 107 clip: { 108 x: 0, 109 y, 110 width: pageWidth + margin * 2, 111 height: box.height + margin * 2, 112 }, 113 }); 114 return true; 115 }; 116 117 for (const section of sections) { 118 const outputPath = join(outputDir, `${section.name}.png`); 119 const ok = await captureWithMargins(section.id, outputPath); 120 if (!ok) { 121 logger.warn(`Section #${section.id} not found, skipping`); 122 continue; 123 } 124 results.push({ name: section.name, path: outputPath }); 125 logger.info(`Captured: ${section.name}.png`); 126 } 127 128 // Also capture a hero preview (cover page scaled down for the landing page hero) 129 const previewPath = join(outputDir, 'report-preview.png'); 130 const previewOk = await captureWithMargins('page-cover', previewPath); 131 if (previewOk) { 132 results.push({ name: 'report-preview', path: previewPath }); 133 logger.info('Captured: report-preview.png'); 134 } 135 136 await context.close(); 137 } finally { 138 await browser.close(); 139 } 140 141 logger.success(`Captured ${results.length} screenshots`); 142 return results; 143 }