Initial commit
This commit is contained in:
256
skills/pdf/scripts/html2poster.js
Executable file
256
skills/pdf/scripts/html2poster.js
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user