chore: preserve presentation sources and exporter

This commit is contained in:
Codex
2026-08-31 15:35:35 +02:00
parent 11f78c4467
commit 919d408c81
9 changed files with 1211 additions and 0 deletions
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Assemble rendered slide PNGs into a 16:9 PPTX: one full-bleed image per slide,
// native speaker notes, document metadata.
//
// Usage:
// node build.mjs <pngdir> <out.pptx> [--notes notes.json] [--title T] [--author A] [--subject S]
import { createRequire } from 'node:module';
import { execSync } from 'node:child_process';
import { readdirSync, readFileSync, existsSync } 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 dir = path.resolve(args[0]);
const out = path.resolve(args[1]);
if (!args[0] || !args[1]) {
console.error('usage: node build.mjs <pngdir> <out.pptx> [--notes notes.json] [--title T] [--author A] [--subject S]');
process.exit(1);
}
const opt = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
const pptxgen = req('pptxgenjs');
const P = new pptxgen();
P.layout = 'LAYOUT_WIDE'; // 13.333 x 7.5 in
P.title = opt('--title', path.basename(out, '.pptx'));
P.author = opt('--author', 'ThothII');
P.subject = opt('--subject', '');
const notesPath = opt('--notes', path.join(dir, 'notes.json'));
const notes = existsSync(notesPath) ? JSON.parse(readFileSync(notesPath, 'utf8')) : {};
const pngs = readdirSync(dir).filter(f => /^\d+\.png$/.test(f)).sort();
if (!pngs.length) { console.error('no NN.png files in ' + dir); process.exit(1); }
for (const f of pngs) {
const n = parseInt(f, 10);
const s = P.addSlide();
s.background = { color: 'FCFAFA' };
s.addImage({ path: path.join(dir, f), x: 0, y: 0, w: 13.333, h: 7.5 });
if (notes[n]) s.addNotes(notes[n]);
}
await P.writeFile({ fileName: out });
console.log('built', out, '-', pngs.length, 'slides');
@@ -0,0 +1,70 @@
#!/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 <deck.html> <outdir> [--hide "sel1,sel2"] [--scale 2] [--width 1600] [--height 900]
//
// Output: <outdir>/01.png .. NN.png + <outdir>/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 <deck.html> <outdir> [--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);