/ src / reports / html-to-pdf.js
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  }