From 218e50f124d7a5fbc8f0510216a9397401c79198 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 15:59:51 +0200 Subject: [PATCH] tools: preserve HTML deck exporter --- tools/html-to-pptx/README.md | 86 ++++++++++ tools/html-to-pptx/package-lock.json | 217 ++++++++++++++++++++++++++ tools/html-to-pptx/package.json | 18 +++ tools/html-to-pptx/scripts/build.mjs | 69 ++++++++ tools/html-to-pptx/scripts/render.mjs | 111 +++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 tools/html-to-pptx/README.md create mode 100644 tools/html-to-pptx/package-lock.json create mode 100644 tools/html-to-pptx/package.json create mode 100644 tools/html-to-pptx/scripts/build.mjs create mode 100644 tools/html-to-pptx/scripts/render.mjs diff --git a/tools/html-to-pptx/README.md b/tools/html-to-pptx/README.md new file mode 100644 index 00000000..c96f2f1d --- /dev/null +++ b/tools/html-to-pptx/README.md @@ -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/.html \ + --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/.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 -- \ + presentations/.pptx \ + --notes presentations/.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. diff --git a/tools/html-to-pptx/package-lock.json b/tools/html-to-pptx/package-lock.json new file mode 100644 index 00000000..1f016d33 --- /dev/null +++ b/tools/html-to-pptx/package-lock.json @@ -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" + } + } +} diff --git a/tools/html-to-pptx/package.json b/tools/html-to-pptx/package.json new file mode 100644 index 00000000..04e88a97 --- /dev/null +++ b/tools/html-to-pptx/package.json @@ -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" + } +} diff --git a/tools/html-to-pptx/scripts/build.mjs b/tools/html-to-pptx/scripts/build.mjs new file mode 100644 index 00000000..095d87f1 --- /dev/null +++ b/tools/html-to-pptx/scripts/build.mjs @@ -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 [--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 [--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'); diff --git a/tools/html-to-pptx/scripts/render.mjs b/tools/html-to-pptx/scripts/render.mjs new file mode 100644 index 00000000..8342b166 --- /dev/null +++ b/tools/html-to-pptx/scripts/render.mjs @@ -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 [--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(); +}