#!/usr/bin/env node /** * html2poster.js — Single-page poster/long-image HTML → PDF converter * * Purpose: Convert a fixed-width, dynamic-height HTML poster into a single-page * vector PDF with zero margins. This script is PURPOSE-BUILT for posters and * infographics — it does NOT handle multi-page documents, A4 pagination, or * document-style margins. For those, use html2pdf-next.js. * * Usage: * node html2poster.js poster.html * node html2poster.js poster.html --output out.pdf * node html2poster.js poster.html --width 720px * node html2poster.js poster.html --width 720px --max-height 8000 * * What it does (in order): * 1. Load HTML in Playwright * 2. Force overflow:hidden on .poster/.page containers (clip decorative overflow) * 3. Inject @page { margin: 0 } (override any existing margin) * 4. Ensure html/body have margin:0, padding:0, matching background * 5. Measure .poster scrollHeight (actual content height) * 6. Generate single-page PDF with exact dimensions * * What it does NOT do: * - No pagination / page breaks * - No A4 fallback * - No margin injection (always zero) * - No cover adaptation * - No pdf-lib post-processing * - No continuous-canvas detection * - No vertical overflow expansion (posters WANT overflow:hidden) */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); // ── Chromium resolution (shared logic with html2pdf-next.js) ── function resolveChromium(chromiumObj) { let exe; try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; } if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe }; const candidates = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome', ]; if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH); for (const c of candidates) { if (fs.existsSync(c)) return { status: 'fallback', executablePath: c }; } return { status: 'missing', executablePath: exe || '' }; } // ── CLI parsing ── function parseArgs(argv) { const tokens = argv.slice(2); let input = null, output = null, width = '720px', maxHeight = 16000; for (let i = 0; i < tokens.length; i++) { const t = tokens[i]; if (t === '--output' || t === '-o') output = tokens[++i]; else if (t === '--width') width = tokens[++i]; else if (t === '--max-height') maxHeight = parseInt(tokens[++i], 10); else if (t === '--help' || t === '-h') { console.log(` Usage: node html2poster.js [options] Options: --output, -o Output PDF path (default: input with .pdf extension) --width Poster width (default: 720px) --max-height Maximum allowed height in px (default: 16000, safety limit) -h, --help Show this help `); process.exit(0); } else if (!input) input = t; else if (!output) output = t; } if (!input) { console.error('Error: No input HTML file specified.'); process.exit(1); } if (!output) { output = input.replace(/\.html?$/i, '.pdf'); if (output === input) output = input + '.pdf'; } return { input, output, width, maxHeight }; } // ── Main ── async function main() { const { input, output, width, maxHeight } = parseArgs(process.argv); const absIn = path.resolve(input); const absOut = path.resolve(output); if (!fs.existsSync(absIn)) { console.error(`Error: File not found: ${absIn}`); process.exit(1); } console.log(`\n🖼 html2poster — Single-page poster PDF generator`); console.log(` Input: ${absIn}`); console.log(` Output: ${absOut}`); console.log(` Width: ${width}`); // Load Playwright let playwright; try { playwright = require('playwright'); } catch { try { playwright = require('playwright-core'); } catch { console.error('Error: playwright or playwright-core not installed.'); process.exit(1); } } const { chromium } = playwright; const bInfo = resolveChromium(chromium); if (bInfo.status === 'missing') { console.error('Error: No Chromium found. Run: npx playwright install chromium'); process.exit(1); } if (bInfo.status === 'fallback') { console.log(` ⚠ Using fallback Chromium: ${bInfo.executablePath}`); } // Launch browser const launchOpts = { headless: true }; if (bInfo.status === 'fallback') launchOpts.executablePath = bInfo.executablePath; const browser = await chromium.launch(launchOpts); try { // Use a wide viewport so content doesn't wrap unexpectedly const widthPx = parseInt(width, 10) || 720; const page = await browser.newPage({ viewport: { width: widthPx, height: 1200 } }); await page.goto('file://' + absIn, { waitUntil: 'networkidle' }); console.log(`\n ✓ HTML loaded`); // ── Step 1: Force overflow:hidden on page containers ── // Decorative elements with negative offsets or width>100% inflate scrollWidth, // causing Playwright to shrink content to fit. overflow:hidden clips them. const overflowFixed = await page.evaluate(() => { const selectors = ['.poster', '.page', '#poster', '#page']; let fixed = 0; for (const sel of selectors) { const el = document.querySelector(sel); if (!el) continue; const computed = getComputedStyle(el); if (computed.overflow !== 'hidden') { el.style.overflow = 'hidden'; fixed++; } } return fixed; }); if (overflowFixed > 0) { console.log(` ✓ Added overflow:hidden to ${overflowFixed} container(s)`); } // ── Step 2: Inject @page { margin: 0 } — override any existing @page rule ── await page.evaluate(() => { const s = document.createElement('style'); // Use !important-equivalent: place at end so it wins cascade s.textContent = `@page { margin: 0 !important; size: auto; }`; document.head.appendChild(s); }); // ── Step 3: Ensure html/body have zero margin/padding ── const bgSync = await page.evaluate(() => { const html = document.documentElement; const body = document.body; html.style.margin = '0'; html.style.padding = '0'; body.style.margin = '0'; body.style.padding = '0'; // Sync body background with poster background to avoid color gaps const poster = document.querySelector('.poster') || document.querySelector('.page'); if (poster) { const posterBg = getComputedStyle(poster).backgroundColor; if (posterBg && posterBg !== 'rgba(0, 0, 0, 0)' && posterBg !== 'transparent') { body.style.backgroundColor = posterBg; html.style.backgroundColor = posterBg; return posterBg; } } return null; }); if (bgSync) { console.log(` ✓ Synced body background: ${bgSync}`); } // ── Step 4: Measure actual content height ── const measurement = await page.evaluate(() => { const poster = document.querySelector('.poster') || document.querySelector('.page') || document.body; return { scrollHeight: poster.scrollHeight, scrollWidth: poster.scrollWidth, offsetWidth: poster.offsetWidth, selector: poster.className ? '.' + poster.className.split(' ')[0] : poster.tagName, }; }); console.log(` ✓ Measured: ${measurement.selector} = ${measurement.scrollWidth}×${measurement.scrollHeight}px`); if (measurement.scrollWidth > widthPx + 2) { console.log(` ⚠ WARNING: scrollWidth (${measurement.scrollWidth}px) > width (${widthPx}px)`); console.log(` Decorative elements may still overflow. Check for position:absolute elements with negative offsets.`); } let contentHeight = measurement.scrollHeight; if (contentHeight > maxHeight) { console.log(` ⚠ Content height ${contentHeight}px exceeds max ${maxHeight}px, clamping.`); contentHeight = maxHeight; } if (contentHeight < 100) { console.log(` ⚠ Content height ${contentHeight}px seems too small, using 960px fallback.`); contentHeight = 960; } // ── Step 5: Generate PDF ── console.log(`\n 📄 Generating PDF: ${width} × ${contentHeight}px`); await page.pdf({ path: absOut, width: width, height: contentHeight + 'px', printBackground: true, margin: { top: '0', right: '0', bottom: '0', left: '0' }, }); console.log(`\n ✅ Done: ${absOut}`); console.log(` Size: ${(fs.statSync(absOut).size / 1024).toFixed(1)} KB`); } finally { await browser.close(); } } main().catch(err => { console.error(`\n✗ Fatal: ${err.message}`); process.exit(1); });