#!/usr/bin/env node // Render each slide of a ThothII-style HTML deck to a high-res PNG. // Assumes the deck exposes .slide sections navigable via #N hash routes. // // Usage: // node render.mjs [--hide "sel1,sel2"] [--scale 2] [--width 1600] [--height 900] // // Output: /01.png .. NN.png + /notes.json (slide number -> title text) import { createRequire } from 'node:module'; import { execSync } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; const require = createRequire(import.meta.url); function req(name) { try { return require(name); } catch {} const g = execSync('npm root -g').toString().trim(); return require(path.join(g, name)); } const args = process.argv.slice(2); const html = path.resolve(args[0]); const outdir = path.resolve(args[1]); if (!args[0] || !args[1]) { console.error('usage: node render.mjs [--hide "sel1,sel2"] [--scale 2] [--width 1600] [--height 900]'); process.exit(1); } const opt = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; }; const hide = (opt('--hide', '') || '').split(',').map(s => s.trim()).filter(Boolean); const scale = Number(opt('--scale', '2')); const width = Number(opt('--width', '1600')); const height = Number(opt('--height', '900')); const { chromium } = req('playwright'); mkdirSync(outdir, { recursive: true }); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: scale, reducedMotion: 'reduce', }); await page.goto('file://' + html, { waitUntil: 'networkidle' }); await page.evaluate(() => document.fonts.ready); if (hide.length) { await page.addStyleTag({ content: hide.map(s => `${s}{display:none!important}`).join('\n') }); } const count = await page.locator('.slide').count(); const notes = {}; for (let i = 1; i <= count; i++) { await page.evaluate(n => { location.hash = '#' + n; }, i); await page.waitForFunction(n => { const s = document.querySelectorAll('.slide')[n - 1]; return !!s && s.classList.contains('on'); }, i); await page.waitForTimeout(450); // let the crossfade settle const file = path.join(outdir, String(i).padStart(2, '0') + '.png'); await page.screenshot({ path: file }); notes[i] = await page.evaluate(n => { const s = document.querySelectorAll('.slide')[n - 1]; const t = s.querySelector('.title, .cover-mark'); const txt = t ? t.innerText.trim().replace(/\s+/g, ' ') : ''; return txt || s.getAttribute('aria-label') || ''; }, i); console.log('rendered', file); } await browser.close(); writeFileSync(path.join(outdir, 'notes.json'), JSON.stringify(notes, null, 2)); console.log('done:', count, 'slides ->', outdir);