export_deck_pdf.mjs
1 #!/usr/bin/env node 2 /** 3 * export_deck_pdf.mjs ā Export multi-file slide deck to a single vector PDF 4 * 5 * Usage: 6 * node export_deck_pdf.mjs --slides <dir> --out <file.pdf> [--width 1920] [--height 1080] 7 * 8 * Features: 9 * - Text retained as vectors (copyable, searchable) 10 * - Background/graphics 1:1 fidelity (Playwright embedded Chromium rendering) 11 * - No HTML changes needed 12 * - Visual loss = 0 (PDF is exactly what the browser prints) 13 * 14 * Trade-off: 15 * - PDF text is not re-editable (go back to HTML for changes) 16 * 17 * Dependencies: playwright pdf-lib 18 * npm install playwright pdf-lib 19 * 20 * Files sorted by filename (01-xxx.html ā 02-xxx.html ā ...) 21 */ 22 23 import { chromium } from 'playwright'; 24 import { PDFDocument } from 'pdf-lib'; 25 import fs from 'fs/promises'; 26 import path from 'path'; 27 28 function parseArgs() { 29 const args = { width: 1920, height: 1080 }; 30 const a = process.argv.slice(2); 31 for (let i = 0; i < a.length; i += 2) { 32 const k = a[i].replace(/^--/, ''); 33 args[k] = a[i + 1]; 34 } 35 if (!args.slides || !args.out) { 36 console.error('Usage: node export_deck_pdf.mjs --slides <dir> --out <file.pdf> [--width 1920] [--height 1080]'); 37 process.exit(1); 38 } 39 args.width = parseInt(args.width); 40 args.height = parseInt(args.height); 41 return args; 42 } 43 44 async function main() { 45 const { slides, out, width, height } = parseArgs(); 46 const slidesDir = path.resolve(slides); 47 const outFile = path.resolve(out); 48 49 const files = (await fs.readdir(slidesDir)) 50 .filter(f => f.endsWith('.html')) 51 .sort(); 52 if (!files.length) { 53 console.error(`No .html files found in ${slidesDir}`); 54 process.exit(1); 55 } 56 console.log(`Found ${files.length} slides in ${slidesDir}`); 57 58 const browser = await chromium.launch(); 59 const ctx = await browser.newContext({ viewport: { width, height } }); 60 61 // 1) Render each HTML to its own PDF buffer 62 const pageBuffers = []; 63 for (const f of files) { 64 const page = await ctx.newPage(); 65 const url = 'file://' + path.join(slidesDir, f); 66 await page.goto(url, { waitUntil: 'networkidle' }).catch(() => page.goto(url)); 67 await page.waitForTimeout(1200); // web-font paint 68 // emulate "screen" so CSS colors/backgrounds render the same as browser 69 await page.emulateMedia({ media: 'screen' }); 70 const buf = await page.pdf({ 71 width: `${width}px`, 72 height: `${height}px`, 73 printBackground: true, 74 margin: { top: 0, right: 0, bottom: 0, left: 0 }, 75 preferCSSPageSize: false, 76 }); 77 pageBuffers.push(buf); 78 await page.close(); 79 console.log(` [${pageBuffers.length}/${files.length}] ${f}`); 80 } 81 82 await browser.close(); 83 84 // 2) Merge into a single PDF 85 const merged = await PDFDocument.create(); 86 for (const buf of pageBuffers) { 87 const src = await PDFDocument.load(buf); 88 const copied = await merged.copyPages(src, src.getPageIndices()); 89 copied.forEach(p => merged.addPage(p)); 90 } 91 const bytes = await merged.save(); 92 await fs.writeFile(outFile, bytes); 93 94 const kb = (bytes.byteLength / 1024).toFixed(0); 95 console.log(`\nā Wrote ${outFile} (${kb} KB, ${files.length} pages, vector)`); 96 } 97 98 main().catch(e => { console.error(e); process.exit(1); });