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
+72
View File
@@ -0,0 +1,72 @@
---
name: html-to-pptx
description: Convert the ThothII HTML slide decks in presentations/ into a pixel-faithful PowerPoint (.pptx) and re-export after edits. Use whenever the user mentions pptx, PowerPoint, Keynote import, "esporta in PowerPoint", "converti le slide", or wants the deck in Office format while keeping the exact look and feel of the HTML version.
---
# HTML deck → PPTX (pixel-faithful)
The decks in `presentations/*.html` are the editable source of truth. The PPTX is a
rendered artifact: one full-bleed, high-resolution PNG per slide inside a 13.33×7.5in
(16:9) deck, plus native speaker notes and document metadata.
**Why images instead of native PowerPoint shapes:** the decks depend on web fonts
(Fraunces, Manrope) and hairline CSS details that PowerPoint silently substitutes on
any machine without those fonts installed. Rendering bakes the fonts into pixels, so
the file projects identically everywhere. The tradeoff: slide text is not editable in
PowerPoint. To change a slide, edit the HTML, then re-export here. Speaker notes stay
native and editable.
## Pipeline
Run from the repo root. The scripts resolve `playwright`/`pptxgenjs` from this
skill's own `node_modules`, falling back to `npm -g`:
1. **Render** every slide to PNG (2× ≈ 240dpi) and auto-extract per-slide titles:
```bash
node .agents/skills/html-to-pptx/scripts/render.mjs \
presentations/<deck>.html <tmpdir> --hide ".hud button,.hint"
```
`--hide` takes a comma-separated selector list for browser-only chrome (nav
buttons, keyboard hints) that must not be baked into a static file. The renderer
assumes the deck exposes `.slide` sections navigable via `#N` hash routes (the
ThothII deck does).
2. **Build** the deck:
```bash
node .agents/skills/html-to-pptx/scripts/build.mjs \
<tmpdir> presentations/<deck>.pptx \
--notes presentations/<deck>.notes.json \
--title "..." --author "..." --subject "..."
```
`render.mjs` writes a raw `<tmpdir>/notes.json` (slide titles); keep the refined,
speaker-ready notes in `presentations/<deck>.notes.json` (same shape:
`{"1": "...", "2": "..."}`) and pass it with `--notes`. Notes are in Italian,
2-4 speakable sentences per slide, and are the only editable text in the PPTX.
## QA before handing over
- python-pptx walk: reopen the file, assert slide count equals the PNG count, slide
size equals 12192000×6858000 EMU (13.333×7.5in), and every slide carries exactly
one picture plus (optionally) a notes slide. Report file size.
- Spot-check one PNG at full size: crisp text, no clipping, serif headlines
(Fraunces) and sans body (Manrope) clearly rendered, no browser buttons visible.
## Requirements (first run only)
```bash
npm install --prefix .agents/skills/html-to-pptx
npx --prefix .agents/skills/html-to-pptx playwright install chromium # only if launch fails
```
QA needs `python-pptx` (any venv: `python3 -m venv /tmp/pptxqa && /tmp/pptxqa/bin/pip install python-pptx`).
## When NOT to use this skill
If the user needs natively editable PowerPoint objects (they will rewrite slide text
inside PowerPoint), build the deck with the `document-skills:pptx` skill instead and
accept the font-substitution risk. Do not silently mix the two outputs: pick one,
state the tradeoff, and deliver one file.
+214
View File
@@ -0,0 +1,214 @@
{
"name": "html-to-pptx-skill",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "html-to-pptx-skill",
"dependencies": {
"playwright": "1.58.0",
"pptxgenjs": "^3.12.0"
}
},
"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"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "html-to-pptx-skill",
"private": true,
"type": "module",
"description": "Dependencies for the html-to-pptx skill (render HTML slides via Playwright, assemble PPTX via pptxgenjs).",
"dependencies": {
"playwright": "1.58.0",
"pptxgenjs": "^3.12.0"
}
}
@@ -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);