Files
mantle-ai-trader/skills/pdf/scripts/html2poster.js
2026-06-06 05:21:10 +00:00

257 lines
8.7 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <input.html> [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);
});