/ scripts / export_deck_pdf.mjs
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); });