tools: preserve HTML deck exporter

This commit is contained in:
Codex
2026-08-31 15:59:51 +02:00
parent e82fe8d322
commit 218e50f124
5 changed files with 501 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
#!/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 { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
const require = createRequire(import.meta.url);
const PptxGenJS = require('pptxgenjs');
const args = process.argv.slice(2);
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 invocationDir = process.env.INIT_CWD || process.cwd();
const dir = path.resolve(invocationDir, args[0]);
const out = path.resolve(invocationDir, args[1]);
const opt = (flag, fallback) => {
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : fallback;
};
const presentation = new PptxGenJS();
presentation.layout = 'LAYOUT_WIDE'; // 13.333 x 7.5 in
presentation.title = opt('--title', path.basename(out, '.pptx'));
presentation.author = opt('--author', 'ThothII');
presentation.subject = opt('--subject', '');
const notesOption = opt('--notes', null);
const notesPath = notesOption
? path.resolve(invocationDir, notesOption)
: path.join(dir, 'notes.json');
const notes = existsSync(notesPath)
? JSON.parse(readFileSync(notesPath, 'utf8'))
: {};
const pngs = readdirSync(dir)
.filter(file => /^\d+\.png$/.test(file))
.sort((left, right) => Number.parseInt(left, 10) - Number.parseInt(right, 10));
if (pngs.length === 0) {
console.error('no NN.png files in ' + dir);
process.exit(1);
}
for (const file of pngs) {
const slideNumber = Number.parseInt(file, 10);
const slide = presentation.addSlide();
slide.background = { color: 'FCFAFA' };
slide.addImage({
path: path.join(dir, file),
x: 0,
y: 0,
w: 13.333,
h: 7.5,
});
if (notes[slideNumber]) {
slide.addNotes(notes[slideNumber]);
}
}
await presentation.writeFile({ fileName: out });
console.log('built', out, '-', pngs.length, 'slides');
+111
View File
@@ -0,0 +1,111 @@
#!/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 {
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 <deck.html> <outdir> [--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();
}