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
+86
View File
@@ -0,0 +1,86 @@
# HTML deck to PPTX
This tool renders a ThothII HTML deck as one full-bleed PNG per slide and assembles
those images into a 16:9 PowerPoint file with native speaker notes and document
metadata. The HTML and notes JSON under `presentations/` are the editable sources;
the PPTX is a rendered release artifact.
Using images preserves the web typography and fine CSS details across machines. The
tradeoff is that slide text is not editable in PowerPoint. Speaker notes remain native
and editable.
## Requirements
- Node.js 18 or newer and npm.
- Playwright's Chromium build.
- Network access while rendering the current deck, because its Fraunces and Manrope
fonts are loaded from Google Fonts. The render is therefore not fully hermetic.
Install the locked JavaScript dependencies and Chromium from the repository root:
```bash
npm ci --prefix tools/html-to-pptx
npx --prefix tools/html-to-pptx playwright install chromium
```
Dependencies are local to this tool. The scripts intentionally do not use globally
installed npm packages.
## Render slides
Use a dedicated render directory. The renderer removes stale files matching
`NN.png`, writes fresh slide images, and creates a raw `notes.json` containing the
title extracted from each slide.
```bash
npm --prefix tools/html-to-pptx run render -- \
presentations/<deck>.html <render-dir> \
--hide ".hud button,.hint"
```
The HTML deck must expose `.slide` elements activated by `#N` hash routes. Optional
rendering flags are `--scale`, `--width`, and `--height`; their defaults are `2`,
`1600`, and `900`.
## Build the PowerPoint file
Keep refined speaker notes in `presentations/<deck>.notes.json`, using the shape
`{"1": "...", "2": "..."}`. Pass that file instead of the raw titles generated
during rendering:
```bash
npm --prefix tools/html-to-pptx run build -- \
<render-dir> presentations/<deck>.pptx \
--notes presentations/<deck>.notes.json \
--title "..." --author "..." --subject "..."
```
The output uses the wide PowerPoint layout (13.333 x 7.5 inches), with one image per
slide. Edit the HTML and rebuild whenever slide content changes.
## Validation
Run the syntax checks with:
```bash
npm --prefix tools/html-to-pptx test
```
Before publishing a regenerated deck, inspect at least one PNG at full resolution and
use `python-pptx` to verify the slide count, the 12192000 x 6858000 EMU page size, and
one picture per slide. `python-pptx` is an optional, currently unpinned QA dependency;
it is not needed to render or build the deck.
The locked direct dependencies are Playwright 1.58.0 (Apache-2.0) and PptxGenJS
3.12.0 (MIT). Their transitive versions and integrity hashes are recorded in
`package-lock.json`.
### Temporary dependency exception
PptxGenJS 3.12.0 declares `image-size` 1.2.1, which npm flags for the availability-only
[GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) and
[GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq) advisories. No patched
`image-size` release exists as of 2026-08-31, and current PptxGenJS releases retain the dependency.
The affected parser is not imported by PptxGenJS's published builder path used here; this tool also
accepts only locally generated PNG slides with explicit dimensions. Do not use it with untrusted
image inputs. Reassess this exception by 2026-11-29, or sooner when a patched dependency is released.
+217
View File
@@ -0,0 +1,217 @@
{
"name": "thothii-html-to-pptx",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "thothii-html-to-pptx",
"dependencies": {
"playwright": "1.58.0",
"pptxgenjs": "3.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pptxgenjs": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
"integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.7.3",
"https": "^1.0.0",
"image-size": "^1.0.0",
"jszip": "^3.7.1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "thothii-html-to-pptx",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"build": "node scripts/build.mjs",
"check": "node --check scripts/render.mjs && node --check scripts/build.mjs",
"render": "node scripts/render.mjs",
"test": "npm run check"
},
"dependencies": {
"playwright": "1.58.0",
"pptxgenjs": "3.12.0"
}
}
+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();
}