#!/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 { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; const require = createRequire(import.meta.url); const { chromium } = require('playwright'); const args = process.argv.slice(2); if (!args[0] || !args[1]) { console.error( 'usage: node render.mjs [--hide "sel1,sel2"] [--scale 2] [--width 1600] [--height 900]', ); process.exit(1); } const invocationDir = process.env.INIT_CWD || process.cwd(); const html = path.resolve(invocationDir, args[0]); const outdir = path.resolve(invocationDir, args[1]); if (!existsSync(html)) { console.error('HTML deck not found: ' + html); process.exit(1); } const opt = (flag, fallback) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : fallback; }; const hide = (opt('--hide', '') || '') .split(',') .map(selector => selector.trim()) .filter(Boolean); const scale = Number(opt('--scale', '2')); const width = Number(opt('--width', '1600')); const height = Number(opt('--height', '900')); mkdirSync(outdir, { recursive: true }); let browser; try { browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: scale, reducedMotion: 'reduce', }); await page.goto(pathToFileURL(html).href, { waitUntil: 'networkidle' }); await page.evaluate(() => document.fonts.ready); if (hide.length) { await page.addStyleTag({ content: hide .map(selector => `${selector}{display:none!important}`) .join('\n'), }); } const count = await page.locator('.slide').count(); if (count === 0) { throw new Error('no .slide elements found in ' + html); } for (const entry of readdirSync(outdir)) { if (/^\d+\.png$/.test(entry)) { unlinkSync(path.join(outdir, entry)); } } const notes = {}; for (let index = 1; index <= count; index++) { await page.evaluate(slideNumber => { location.hash = '#' + slideNumber; }, index); await page.waitForFunction(slideNumber => { const slide = document.querySelectorAll('.slide')[slideNumber - 1]; return Boolean(slide) && slide.classList.contains('on'); }, index); await page.waitForTimeout(450); // let the crossfade settle const file = path.join(outdir, String(index).padStart(2, '0') + '.png'); await page.screenshot({ path: file }); notes[index] = await page.evaluate(slideNumber => { const slide = document.querySelectorAll('.slide')[slideNumber - 1]; const title = slide.querySelector('.title, .cover-mark'); const text = title ? title.innerText.trim().replace(/\s+/g, ' ') : ''; return text || slide.getAttribute('aria-label') || ''; }, index); console.log('rendered', file); } writeFileSync( path.join(outdir, 'notes.json'), JSON.stringify(notes, null, 2), ); console.log('done:', count, 'slides ->', outdir); } finally { await browser?.close(); }