/ scripts / export_deck_pptx.mjs
export_deck_pptx.mjs
  1  #!/usr/bin/env node
  2  /**
  3   * export_deck_pptx.mjs — Export multi-file slide deck to PPTX
  4   *
  5   * Two modes:
  6   *   --mode image     Image-based, 100% visual fidelity, text NOT editable
  7   *   --mode editable  Native text boxes, text editable, requires HTML following 4 hard constraints (see references/editable-pptx.md)
  8   *
  9   * Usage:
 10   *   # Image mode (default)
 11   *   node export_deck_pptx.mjs --slides <dir> --out <file.pptx>
 12   *   # Editable mode
 13   *   node export_deck_pptx.mjs --slides <dir> --out <file.pptx> --mode editable
 14   *
 15   * --mode image features:
 16   *   - Each slide screenshot as PNG, fills one PPTX page
 17   *   - 100% visual fidelity (it's an image)
 18   *   - Text not editable
 19   *   - HTML can be anything, no format requirements
 20   *
 21   * --mode editable features:
 22   *   - Calls scripts/html2pptx.js to translate HTML DOM elements into PowerPoint objects
 23   *   - Text is real text boxes, double-click to edit in PowerPoint
 24   *   - HTML must follow 4 hard constraints (see references/editable-pptx.md):
 25   *     1. Text wrapped in <p>/<h1>-<h6> (no bare text in divs)
 26   *     2. No CSS gradients
 27   *     3. <p>/<h*> cannot have background/border/shadow (put on outer div)
 28   *     4. No background-image on divs (use <img>)
 29   *   - Body dimensions default 960pt × 540pt (LAYOUT_WIDE, 13.333" × 7.5")
 30   *   - Visually-driven HTML almost never passes — must follow constraints from the first line
 31   *
 32   * Dependencies:
 33   *   --mode image:    npm install playwright pptxgenjs
 34   *   --mode editable: npm install playwright pptxgenjs sharp
 35   *
 36   * Files sorted by filename (01-xxx.html → 02-xxx.html → ...).
 37   */
 38  
 39  import { chromium } from 'playwright';
 40  import pptxgen from 'pptxgenjs';
 41  import fs from 'fs/promises';
 42  import path from 'path';
 43  import os from 'os';
 44  import { fileURLToPath } from 'url';
 45  
 46  const __dirname = path.dirname(fileURLToPath(import.meta.url));
 47  
 48  function parseArgs() {
 49    const args = { width: 1920, height: 1080, mode: 'image' };
 50    const a = process.argv.slice(2);
 51    for (let i = 0; i < a.length; i += 2) {
 52      const k = a[i].replace(/^--/, '');
 53      args[k] = a[i + 1];
 54    }
 55    if (!args.slides || !args.out) {
 56      console.error('Usage: node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--mode image|editable] [--width 1920] [--height 1080]');
 57      process.exit(1);
 58    }
 59    args.width = parseInt(args.width);
 60    args.height = parseInt(args.height);
 61    if (!['image', 'editable'].includes(args.mode)) {
 62      console.error(`Unknown --mode: ${args.mode}. Supported: image, editable`);
 63      process.exit(1);
 64    }
 65    return args;
 66  }
 67  
 68  async function exportImage({ slidesDir, outFile, files, width, height }) {
 69    console.log(`[image mode] Rendering ${files.length} slides as PNG...`);
 70  
 71    const browser = await chromium.launch();
 72    const ctx = await browser.newContext({ viewport: { width, height } });
 73    const page = await ctx.newPage();
 74  
 75    const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deck-pptx-'));
 76    const pngs = [];
 77    for (const f of files) {
 78      const url = 'file://' + path.join(slidesDir, f);
 79      await page.goto(url, { waitUntil: 'networkidle' }).catch(() => page.goto(url));
 80      await page.waitForTimeout(1200);
 81      const out = path.join(tmpDir, f.replace(/\.html$/, '.png'));
 82      await page.screenshot({ path: out, fullPage: false });
 83      pngs.push(out);
 84      console.log(`  [${pngs.length}/${files.length}] ${f}`);
 85    }
 86    await browser.close();
 87  
 88    const pres = new pptxgen();
 89    pres.defineLayout({ name: 'DECK', width: width / 96, height: height / 96 });
 90    pres.layout = 'DECK';
 91    for (const png of pngs) {
 92      const s = pres.addSlide();
 93      s.addImage({ path: png, x: 0, y: 0, w: pres.width, h: pres.height });
 94    }
 95    await pres.writeFile({ fileName: outFile });
 96  
 97    for (const p of pngs) await fs.unlink(p).catch(() => {});
 98    await fs.rmdir(tmpDir).catch(() => {});
 99  
100    console.log(`\n✓ Wrote ${outFile}  (${files.length} slides, image mode, text not editable)`);
101  }
102  
103  async function exportEditable({ slidesDir, outFile, files }) {
104    console.log(`[editable mode] Converting ${files.length} slides via html2pptx...`);
105  
106    // Dynamic require html2pptx.js (CommonJS module)
107    const { createRequire } = await import('module');
108    const require = createRequire(import.meta.url);
109    let html2pptx;
110    try {
111      html2pptx = require(path.join(__dirname, 'html2pptx.js'));
112    } catch (e) {
113      console.error(`✗ Failed to load html2pptx.js: ${e.message}`);
114      console.error(`  This module depends on sharp — run npm install sharp and retry.`);
115      process.exit(1);
116    }
117  
118    const pres = new pptxgen();
119    pres.layout = 'LAYOUT_WIDE';  // 13.333 × 7.5 inch, corresponding to HTML body 960 × 540 pt
120  
121    const errors = [];
122    for (let i = 0; i < files.length; i++) {
123      const f = files[i];
124      const fullPath = path.join(slidesDir, f);
125      try {
126        await html2pptx(fullPath, pres);
127        console.log(`  [${i + 1}/${files.length}] ${f} ✓`);
128      } catch (e) {
129        console.error(`  [${i + 1}/${files.length}] ${f} ✗  ${e.message}`);
130        errors.push({ file: f, error: e.message });
131      }
132    }
133  
134    if (errors.length) {
135      console.error(`\n⚠️ ${errors.length} slides failed conversion. Common cause: HTML doesn't follow the 4 hard constraints.`);
136      console.error(`  See references/editable-pptx.md "Common Errors Quick Reference".`);
137      if (errors.length === files.length) {
138        console.error(`✗ All failed, no PPTX generated.`);
139        process.exit(1);
140      }
141    }
142  
143    await pres.writeFile({ fileName: outFile });
144    console.log(`\n✓ Wrote ${outFile}  (${files.length - errors.length}/${files.length} slides, editable mode, text directly editable in PowerPoint)`);
145  }
146  
147  async function main() {
148    const { slides, out, width, height, mode } = parseArgs();
149    const slidesDir = path.resolve(slides);
150    const outFile = path.resolve(out);
151  
152    const files = (await fs.readdir(slidesDir))
153      .filter(f => f.endsWith('.html'))
154      .sort();
155    if (!files.length) {
156      console.error(`No .html files found in ${slidesDir}`);
157      process.exit(1);
158    }
159  
160    if (mode === 'image') {
161      await exportImage({ slidesDir, outFile, files, width, height });
162    } else {
163      await exportEditable({ slidesDir, outFile, files });
164    }
165  }
166  
167  main().catch(e => { console.error(e); process.exit(1); });