, layout-text IDs, exclude code
paragraphs
---
docs/string-table.md | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/docs/string-table.md b/docs/string-table.md
index 38cd4ee..2c88212 100644
--- a/docs/string-table.md
+++ b/docs/string-table.md
@@ -46,16 +46,20 @@ notes. Empty paragraphs are not emitted.
| `s.sh.p` | Paragraph `n` (1-based) of the shape whose `cNvPr id` is `shapeId` |
| `s.sh.rc .p` | Paragraph in a table cell |
| `s.sh.alt` | Alt text of a shape or picture |
+| `s.L.sh.p` | Text from a layout or master shape drawn on that slide (logos, labels) |
| `s.notes.p` | Speaker note paragraph |
Shape IDs come from the PPTX, so they survive reordering shapes within a slide.
Slide numbers do not survive reordering slides; re-running the converter after
reordering means re-translating.
+Paragraphs set entirely in a monospace font are code. They stay in the HTML but
+are left out of the table, so nothing translates them.
+
### Inline markup in `text`
-The only tags allowed are ``, ``, ``, ``, ``, `
` and
-``. Everything else is escaped (`<`, `>`, `&`).
+The only tags allowed are ``, ``, ``, ``, ``, ``, `
`
+and ``. Everything else is escaped (`<`, `>`, `&`).
Translators must keep every tag, in matching pairs, and must not change `href`
values. Tag order may change when word order does. A translation that fails this
From c17dc00d99c7cfa95e461ced5b30f47707a03922 Mon Sep 17 00:00:00 2001
From: Ivor Berry <11559977+ivorb@users.noreply.github.com>
Date: Wed, 4 Nov 2026 14:30:49 -0500
Subject: [PATCH 3/9] Convert PPTX to localizable positioned HTML
Adds a converter that turns a .pptx into fixed-canvas HTML with every paragraph,
note and alt text in a string table (docs/string-table.md), a browser page to
convert, preview and download, standalone single-file output with embedded
translations, fit-to-box for longer text, and gates PDF export behind a manual
workflow input.
---
.github/workflows/publish-decks.yml | 10 +
README.md | 57 +-
assets/deck-i18n.js | 120 ++++
assets/pptx-slide.css | 140 +++++
convert.html | 513 +++++++++++++++
deck.html | 67 ++
docs/string-table.md | 22 +-
index.html | 3 +-
tests/pptx-html.test.mjs | 110 ++++
tools/build-manifest.mjs | 10 +-
tools/build-site.mjs | 76 ++-
tools/pptx-geom.mjs | 269 ++++++++
tools/pptx-html.mjs | 932 ++++++++++++++++++++++++++++
tools/pptx-style.mjs | 333 ++++++++++
tools/pptx-to-html.mjs | 85 +++
tools/standalone-html.mjs | 109 ++++
tools/xml-lite.mjs | 106 ++++
tools/zip-tools.mjs | 90 +++
18 files changed, 3039 insertions(+), 13 deletions(-)
create mode 100644 assets/deck-i18n.js
create mode 100644 assets/pptx-slide.css
create mode 100644 convert.html
create mode 100644 tests/pptx-html.test.mjs
create mode 100644 tools/pptx-geom.mjs
create mode 100644 tools/pptx-html.mjs
create mode 100644 tools/pptx-style.mjs
create mode 100644 tools/pptx-to-html.mjs
create mode 100644 tools/standalone-html.mjs
create mode 100644 tools/xml-lite.mjs
create mode 100644 tools/zip-tools.mjs
diff --git a/.github/workflows/publish-decks.yml b/.github/workflows/publish-decks.yml
index 916157b..c9d7ed1 100644
--- a/.github/workflows/publish-decks.yml
+++ b/.github/workflows/publish-decks.yml
@@ -12,6 +12,11 @@ on:
branches: [main]
pull_request:
workflow_dispatch:
+ inputs:
+ export_pdfs:
+ description: Also export a PDF per deck (dormant by default)
+ type: boolean
+ default: false
permissions:
contents: read
@@ -46,13 +51,17 @@ jobs:
# Serving dist/ (not the source) means the PDFs are rendered from the exact
# bytes we ship, so a broken vendored asset fails the build here.
+ # PDF export is dormant: it only runs from a manual "Run workflow" with
+ # export_pdfs ticked. Push and pull_request runs skip these three steps.
- name: Serve built site
+ if: inputs.export_pdfs
working-directory: dist
run: |
npx --yes http-server . -p "$DECK_PORT" --silent &
npx --yes wait-on -t 60000 "http://127.0.0.1:$DECK_PORT/index.html"
- name: Export a PDF per deck
+ if: inputs.export_pdfs
working-directory: dist
run: |
slugs=$(node -e "console.log(require('./decks.json').decks.map(d => d.slug).join('\n'))")
@@ -78,6 +87,7 @@ jobs:
if-no-files-found: error
- name: Upload PDFs
+ if: inputs.export_pdfs
uses: actions/upload-artifact@v4
with:
name: deck-pdfs
diff --git a/README.md b/README.md
index 19cc96a..2318e4b 100644
--- a/README.md
+++ b/README.md
@@ -38,24 +38,30 @@ built `standalone/` files are exempt, since everything is inlined into them.
decks-as-code/
├── index.html landing page, lists every deck
├── deck.html the player: deck.html?d=
+├── convert.html .pptx to localizable HTML, in the browser
├── decks.json generated listing, don't hand-edit
-├── assets/theme.css shared brand styling for all decks
+├── assets/ theme.css (brand), pptx-slide.css (converted slides),
+│ deck-i18n.js (language swap, fit-to-box)
+├── docs/string-table.md the localization contract
├── tools/
│ ├── build-manifest.mjs regenerates decks.json
│ ├── build-site.mjs builds dist/: offline site plus single-file decks
-│ └── import-pptx.mjs converts a .pptx into a deck folder
+│ ├── import-pptx.mjs converts a .pptx into a Markdown deck folder
+│ └── pptx-to-html.mjs converts a .pptx into an HTML deck folder (pptx-html.mjs)
└── decks/
└── /
├── slides.md the deck. The only file you edit day to day.
├── deck.json title, description, author, date, tags, theme
└── images/ optional, referenced as 
+ (decks converted to HTML have slides.html and strings.json instead of slides.md)
```
| URL | Serves |
| --- | --- |
| `/` | Landing page listing all decks |
| `/deck.html?d=` | That deck, full screen |
-| `/decks//deck.pdf` | PDF export, once built |
+| `/deck.html?d=&lang=de` | An HTML deck in a translation (needs `strings.de.json`) |
+| `/decks//deck.pdf` | PDF export, once built (off by default) |
---
@@ -142,6 +148,43 @@ Options:
---
+## Convert a PowerPoint to localizable HTML
+
+A second path, for when the slides must look like the original and every word has to
+be translatable. Instead of Markdown, a `.pptx` becomes positioned HTML: one fixed
+canvas per slide, shapes placed where PowerPoint put them, and all text kept as real
+text.
+
+Open `convert.html` on the served site (or `node tools/pptx-to-html.mjs talk.pptx
+--slug my-talk`). It reads the file in your browser, never uploads it, and lets you:
+
+- **Preview** the slides, with speaker notes.
+- **Look through** the generated `slides.html` and the string table, and see what the
+ converter could not handle.
+- **Download** a single HTML file you can double-click, or the deck folder as a ZIP.
+
+The folder holds `slides.html`, `strings.json` and `images/`. `strings.json` lists
+every paragraph, speaker note and alt text under a stable ID, in the format described
+in [`docs/string-table.md`](docs/string-table.md); translations are `strings..json`
+files in the same folder, and `?lang=` on the deck or standalone file shows one.
+Longer translated text shrinks to fit its box (down to 60%); add `&review` to the
+address to outline boxes that still overflow.
+
+What is kept: every paragraph (including hidden-slide text and notes), bold, italic,
+underline, links, bullets and numbering, fonts and colors, positions, rotation, groups,
+pictures with cropping, tables, gradients, and the common shapes and connectors with
+arrowheads. SmartArt is rendered from its stored drawing when the file has one.
+
+What is not: text inside pictures (it cannot be translated), charts (shown as a
+placeholder), EMF/WMF pictures (browsers cannot draw them), animations, transitions,
+and shadows or other effects. Shapes with an unusual preset are drawn as rectangles.
+Everything the converter skipped or approximated is listed under "Needs a look".
+
+Code paragraphs (monospace text) stay on the slide but are left out of the string
+table, so nothing translates them.
+
+---
+
## Writing slides
`slides.md` is ordinary Markdown with three separator conventions:
@@ -181,8 +224,12 @@ from your file system and they now say so rather than failing silently. To run t
whole site locally, serve `dist/` on a port as shown under "Run it locally".
`.github/workflows/publish-decks.yml` runs on every push and pull request. It builds
-`dist/`, exports a PDF per deck, and uploads two artifacts: `deck-standalone` and
-`deck-pdfs`. On `main` it also deploys `dist/` to GitHub Pages.
+`dist/` and uploads the `deck-standalone` artifact. On `main` it also deploys `dist/`
+to GitHub Pages.
+
+**PDF export is dormant.** The workflow no longer exports PDFs on push or pull
+request. The steps are still there: run the workflow manually (Actions → Publish
+decks → Run workflow) and tick `export_pdfs` to get a `deck-pdfs` artifact.
**GitHub Pages.** This repo is public, so Pages is enabled and every push to `main`
republishes . Settings → Pages → Source is set
diff --git a/assets/deck-i18n.js b/assets/deck-i18n.js
new file mode 100644
index 0000000..21ea18b
--- /dev/null
+++ b/assets/deck-i18n.js
@@ -0,0 +1,120 @@
+/*
+ * Swaps localized text into a deck converted from PowerPoint.
+ *
+ * Every localizable node carries data-i18n="" (or data-i18n-alt for image
+ * alt text). A translation is a table { strings: { "": { text } } }. Anything
+ * missing keeps its source text, so a deck never renders blank.
+ * See docs/string-table.md.
+ *
+ * A classic script, shared by deck.html and the standalone single-file build.
+ */
+(function () {
+ var RTL = { ar: 1, he: 1, fa: 1, ur: 1 };
+ var ALLOWED = { B: 1, I: 1, U: 1, SUP: 1, SUB: 1, CODE: 1, BR: 1, A: 1 };
+
+ /** Rebuild a translation from the contract's inline tags only; drop everything else. */
+ function safeFragment(html) {
+ var tpl = document.createElement('template');
+ tpl.innerHTML = html;
+ var frag = document.createDocumentFragment();
+ (function clean(src, dest) {
+ for (var i = 0; i < src.childNodes.length; i++) {
+ var n = src.childNodes[i];
+ if (n.nodeType === 3) dest.appendChild(document.createTextNode(n.textContent));
+ else if (n.nodeType === 1 && ALLOWED[n.tagName]) {
+ var el = document.createElement(n.tagName);
+ if (n.tagName === 'A') {
+ var href = n.getAttribute('href') || '';
+ if (/^(https?:|mailto:)/i.test(href)) el.setAttribute('href', href);
+ }
+ clean(n, el);
+ dest.appendChild(el);
+ } else if (n.nodeType === 1) clean(n, dest);
+ }
+ })(tpl.content, frag);
+ return frag;
+ }
+
+ function plainText(html) {
+ var tpl = document.createElement('template');
+ tpl.innerHTML = html;
+ return tpl.content.textContent;
+ }
+
+ /** Apply a translation table. `source` restores the original text first. */
+ function apply(table, lang) {
+ document.querySelectorAll('[data-i18n]').forEach(function (el) {
+ if (el.dataset.i18nSrc === undefined) el.dataset.i18nSrc = el.innerHTML;
+ var hit = table && table.strings && table.strings[el.dataset.i18n];
+ if (hit && hit.text) el.replaceChildren(safeFragment(hit.text));
+ else el.innerHTML = el.dataset.i18nSrc;
+ });
+ document.querySelectorAll('[data-i18n-alt]').forEach(function (el) {
+ if (el.dataset.i18nSrc === undefined) el.dataset.i18nSrc = el.alt;
+ var hit = table && table.strings && table.strings[el.dataset.i18nAlt];
+ el.alt = hit && hit.text ? plainText(hit.text) : el.dataset.i18nSrc;
+ });
+ if (lang) {
+ document.documentElement.lang = lang;
+ document.documentElement.dir = RTL[lang.split('-')[0]] ? 'rtl' : 'ltr';
+ }
+ }
+
+ function langFromUrl() {
+ var lang = new URLSearchParams(window.location.search).get('lang');
+ return lang && /^[a-z]{2,3}(-[A-Za-z0-9]+)*$/.test(lang) ? lang : null;
+ }
+
+ var FIT_FLOOR = 0.6;
+ var FIT_STEP = 0.05;
+
+ /**
+ * Longer text (a translation, or a fallback font that runs wide) must not spill
+ * out of its box and over its neighbours. Paragraph sizes are written as
+ * calc(var(--fit, 1) * Npx), so shrinking a box's text is one custom property.
+ * Boxes that still overflow at the floor are flagged with data-overflow.
+ * Only visible slides can be measured, so this runs per slide on demand.
+ */
+ function fitSlide(slide) {
+ if (!slide) return;
+ slide.querySelectorAll('.sh').forEach(function (sh) {
+ var tx = sh.querySelector(':scope > .tx');
+ if (!tx) return;
+ var limit = parseFloat(sh.style.minHeight);
+ if (!limit) return;
+ tx.style.removeProperty('--fit');
+ sh.removeAttribute('data-overflow');
+ var scale = 1;
+ while (tx.offsetHeight > limit + 1 && scale > FIT_FLOOR) {
+ scale = Math.round((scale - FIT_STEP) * 100) / 100;
+ tx.style.setProperty('--fit', String(scale));
+ }
+ if (tx.offsetHeight > limit + 1) sh.setAttribute('data-overflow', 'true');
+ });
+ slide.dataset.fitted = '1';
+ }
+
+ /** Fit the current slide now and on every navigation; call once after Reveal starts. */
+ function autoFit(reveal) {
+ // ?review outlines boxes whose text still overflows after shrinking.
+ if (new URLSearchParams(window.location.search).has('review')) document.documentElement.classList.add('deck-review');
+ var run = function () { fitSlide(reveal.getCurrentSlide()); };
+ reveal.on('ready', run);
+ reveal.on('slidechanged', run);
+ reveal.on('resize', function () {
+ var slide = reveal.getCurrentSlide();
+ if (slide) delete slide.dataset.fitted;
+ });
+ if (reveal.isReady && reveal.isReady()) run();
+ }
+
+ /** Forget measurements, e.g. after the language changed. */
+ function refit(reveal) {
+ document.querySelectorAll('[data-fitted]').forEach(function (s) { delete s.dataset.fitted; });
+ if (reveal) fitSlide(reveal.getCurrentSlide());
+ }
+
+ window.DeckI18n = {
+ apply: apply, safeFragment: safeFragment, langFromUrl: langFromUrl, fitSlide: fitSlide, autoFit: autoFit, refit: refit,
+ };
+})();
diff --git a/assets/pptx-slide.css b/assets/pptx-slide.css
new file mode 100644
index 0000000..b5211ad
--- /dev/null
+++ b/assets/pptx-slide.css
@@ -0,0 +1,140 @@
+/*
+ * Styling for slides converted from PowerPoint (decks with "format": "html").
+ * Everything is scoped under .pptx-canvas so it can sit inside a Reveal theme
+ * without picking up the theme's margins, fonts or table styling.
+ */
+
+.reveal .slides > section.pptx-slide {
+ padding: 0 !important;
+ text-align: left;
+}
+
+.reveal .pptx-canvas {
+ position: relative;
+ overflow: hidden;
+ text-align: left;
+ font-size: 16px;
+ line-height: 1.2;
+ color: #000;
+ font-family: 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
+ text-shadow: none;
+ text-transform: none;
+}
+
+.reveal .pptx-canvas svg.defs {
+ position: absolute;
+ width: 0;
+ height: 0;
+}
+
+.reveal .pptx-canvas .sh,
+.reveal .pptx-canvas .pic,
+.reveal .pptx-canvas .cx,
+.reveal .pptx-canvas .tb,
+.reveal .pptx-canvas .miss {
+ position: absolute;
+ box-sizing: border-box;
+}
+
+/* Fill and outline layers grow with the text box, so long translations keep their card. */
+.reveal .pptx-canvas .bg,
+.reveal .pptx-canvas .ln {
+ position: absolute;
+ inset: 0;
+ box-sizing: border-box;
+}
+
+.reveal .pptx-canvas .ln {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+}
+
+.reveal .pptx-canvas .tx {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+.reveal .pptx-canvas p {
+ position: relative;
+ margin: 0;
+ padding: 0;
+ max-width: none;
+ text-align: inherit;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+.reveal .pptx-canvas .tx > p:first-child {
+ margin-top: 0 !important;
+}
+
+.reveal .pptx-canvas p.e::before {
+ content: '\200b';
+}
+
+.reveal .pptx-canvas p[data-bu]::before {
+ content: attr(data-bu);
+ position: absolute;
+ left: var(--bl, 0);
+ color: var(--buc, inherit);
+ font-size: var(--bus, 100%);
+}
+
+.reveal .pptx-canvas a {
+ color: var(--hl, #0563c1);
+ text-decoration: underline;
+}
+
+.reveal .pptx-canvas code {
+ font-family: Consolas, 'Cascadia Mono', Menlo, monospace;
+ font-size: inherit;
+ padding: 0;
+ background: none;
+ color: inherit;
+}
+
+.reveal .pptx-canvas .pic img {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ border: 0;
+ background: none;
+ box-shadow: none;
+}
+
+.reveal .pptx-canvas .miss {
+ border: 2px dashed #b0b0b0;
+ background: repeating-linear-gradient(45deg, #f4f4f4, #f4f4f4 8px, #ececec 8px, #ececec 16px);
+}
+
+.reveal .pptx-canvas table.tb {
+ margin: 0;
+ border-collapse: collapse;
+ border-spacing: 0;
+ table-layout: fixed;
+}
+
+.reveal .pptx-canvas table.tb td {
+ text-align: left;
+ font-size: inherit;
+ border: 0;
+ overflow-wrap: break-word;
+}
+
+.reveal .pptx-canvas aside.notes {
+ display: none;
+}
+
+html.deck-review .reveal .pptx-canvas .sh[data-overflow] {
+ outline: 2px dashed #e5484d;
+}
diff --git a/convert.html b/convert.html
new file mode 100644
index 0000000..39150a3
--- /dev/null
+++ b/convert.html
@@ -0,0 +1,513 @@
+
+
+
+
+
+ Convert a PowerPoint to HTML · Decks as Code
+
+
+
+
+
+ ← All decks
+
+
+ Convert a PowerPoint to HTML
+
+ Turns a .pptx into HTML slides with every piece of text kept as real,
+ translatable text. The file is read here in your browser and never uploaded
+ anywhere.
+
+
+
+
+ Drop a .pptx here
+ or click to choose one
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deck.html b/deck.html
index b9a1895..8f62e5e 100644
--- a/deck.html
+++ b/deck.html
@@ -131,6 +131,11 @@ To run the whole site
document.title = meta.title || slug;
await loadStyles(meta.theme || 'night');
+ if (meta.format === 'html') {
+ await bootHtmlDeck(base, meta, rebase);
+ return;
+ }
+
// Confirm slides.md exists before handing off to the Markdown plugin,
// which fails silently on a 404 and leaves a blank deck.
const slidesUrl = `${base}/slides.md`;
@@ -215,6 +220,68 @@ To run the whole site
window.DECK_READY = true;
}
+ /*
+ * Decks imported from PowerPoint ship ready-made HTML (slides.html): fixed
+ * canvases with shapes already placed, plus strings.json for localization.
+ * Reveal only handles navigation and scaling for them.
+ */
+ async function bootHtmlDeck(base, meta, rebase) {
+ const slidesUrl = `${base}/slides.html`;
+ let html = null;
+ try {
+ const res = await fetch(slidesUrl, { cache: 'no-cache' });
+ if (res.ok) html = await res.text();
+ } catch {
+ /* handled below */
+ }
+ await loadScripts(false);
+ if (html === null) {
+ fail('Deck not found', `No slides at ${slidesUrl}`);
+ return;
+ }
+
+ await Promise.all([loadCss('assets/pptx-slide.css'), loadScript('assets/deck-i18n.js')]);
+ document.documentElement.lang = meta.lang || 'en';
+ const canvas = meta.canvas || { width: 1280, height: 720 };
+ document.querySelector('.slides').innerHTML = html.replace(
+ /(\bsrc="|url\()(?!data:|https?:|\/)([^")]+)/g,
+ (m, head, ref) => head + rebase(ref),
+ );
+
+ await applyLanguage(base, meta);
+
+ await Reveal.initialize({
+ plugins: [RevealNotes],
+ hash: true,
+ slideNumber: 'c/t',
+ transition: 'none',
+ width: canvas.width,
+ height: canvas.height,
+ margin: 0,
+ minScale: 0.2,
+ maxScale: 3,
+ center: false,
+ pdfSeparateFragments: false,
+ });
+ DeckI18n.autoFit(Reveal);
+ window.DECK_READY = true;
+ }
+
+ /*
+ * ?lang=de swaps every data-i18n node for its entry in strings.de.json
+ * (see assets/deck-i18n.js and docs/string-table.md).
+ */
+ async function applyLanguage(base, meta) {
+ const lang = DeckI18n.langFromUrl();
+ if (!lang || lang === meta.lang) return;
+ try {
+ const res = await fetch(`${base}/strings.${encodeURIComponent(lang)}.json`, { cache: 'no-cache' });
+ if (res.ok) DeckI18n.apply(await res.json(), lang);
+ } catch {
+ /* keep the source language */
+ }
+ }
+
/*
* Slides converted from PowerPoint routinely hold more than fits a 720
* unit canvas, and Reveal does not shrink content to fit. Step the slide's
diff --git a/docs/string-table.md b/docs/string-table.md
index 2c88212..c15c055 100644
--- a/docs/string-table.md
+++ b/docs/string-table.md
@@ -46,7 +46,8 @@ notes. Empty paragraphs are not emitted.
| `s.sh.p` | Paragraph `n` (1-based) of the shape whose `cNvPr id` is `shapeId` |
| `s.sh.rc .p` | Paragraph in a table cell |
| `s.sh.alt` | Alt text of a shape or picture |
-| `s.L.sh.p` | Text from a layout or master shape drawn on that slide (logos, labels) |
+| `s.L.sh.p` | Text from a layout shape drawn on that slide (logos, labels) |
+| `s.M.sh.p` | Same, from the slide master |
| `s.notes.p` | Speaker note paragraph |
Shape IDs come from the PPTX, so they survive reordering shapes within a slide.
@@ -88,6 +89,25 @@ check is invalid.
- Right-to-left languages (`ar`, `he`, `fa`, `ur`) set `dir="rtl"` on the deck
root, along with `lang`.
+## In the HTML
+
+`slides.html` marks every translatable node so a viewer can swap text without
+knowing anything about PowerPoint:
+
+| Marker | Meaning |
+| --- | --- |
+| `data-i18n=""` | Replace this element's contents with the string's `text` |
+| `data-i18n-alt=""` | Replace this image's `alt` with the string's text, tags removed |
+| `data-nt` | Code or other text that is deliberately not translated |
+
+`assets/deck-i18n.js` does the swap (`DeckI18n.apply(table, lang)`) and rebuilds
+each translation from the allowed tags only, so a table cannot inject markup.
+`deck.html?d=&lang=de` and the standalone file's `?lang=de` both use it.
+
+Longer text is expected: after a swap, any box whose text no longer fits is
+shrunk (down to 60%) so it does not run over its neighbours. Add `&review` to the
+address to outline boxes that still overflow.
+
## Rules for consumers
1. Never edit `strings.json` by hand. Fix the PPTX and re-run the converter.
diff --git a/index.html b/index.html
index b521e6e..0178329 100644
--- a/index.html
+++ b/index.html
@@ -157,7 +157,8 @@ Decks as Code
Have a deck in PowerPoint already?
- Import a .pptx →
+ Convert a .pptx to HTML → ·
+ Import a .pptx as Markdown →
diff --git a/tests/pptx-html.test.mjs b/tests/pptx-html.test.mjs
new file mode 100644
index 0000000..70bd257
--- /dev/null
+++ b/tests/pptx-html.test.mjs
@@ -0,0 +1,110 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { parseXml, child, kids, path } from '../tools/xml-lite.mjs';
+import { listZipEntries } from '../tools/pptx-core.mjs';
+import { makeZip } from '../tools/zip-tools.mjs';
+import { pptxToHtml } from '../tools/pptx-html.mjs';
+import { presetGeometry } from '../tools/pptx-geom.mjs';
+
+const NS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
+const enc = new TextEncoder();
+
+/** Builds a minimal but valid .pptx package as the Map pptxToHtml expects. */
+async function buildPptx(parts) {
+ const blob = makeZip(Object.entries(parts).map(([name, text]) => ({ name, bytes: enc.encode(text) })));
+ const bytes = new Uint8Array(await blob.arrayBuffer());
+ const files = new Map();
+ for (const entry of listZipEntries(bytes)) {
+ files.set(entry.name, bytes.subarray(entry.dataStart, entry.dataStart + entry.compSize));
+ }
+ return files;
+}
+
+const rels = (items) => `${items.map(([id, type, target, ext]) => ` `).join('')} `;
+
+const slide = (shapes) => ` ${shapes} `;
+
+const textBox = (id, x, y, cx, cy, paragraphs, hidden = false) => ` ${paragraphs} `;
+
+const para = (...runs) => `${runs.join('')} `;
+const run = (text, attrs = '', inner = '') => `${inner} ${text} `;
+
+async function sample() {
+ const shapes = [
+ textBox(2, 914400, 914400, 4572000, 914400, para(run('Hello '), run('bold & <tags>', 'b="1"'), run(' end'))),
+ textBox(3, 914400, 2286000, 4572000, 914400, para(run('x = 1', '', ' '))),
+ textBox(4, 914400, 3200400, 4572000, 457200, para(run('See '), run('docs', '', ' '))),
+ ` ${textBox(6, 0, 0, 1000000, 500000, para(run('Rotated')))} `,
+ textBox(7, 0, 0, 100, 100, para(run('Hidden text')), true),
+ ].join('');
+ return buildPptx({
+ 'ppt/presentation.xml': ` `,
+ 'ppt/_rels/presentation.xml.rels': rels([['rId1', 'slide', 'slides/slide1.xml']]),
+ 'ppt/slides/slide1.xml': slide(shapes),
+ 'ppt/slides/_rels/slide1.xml.rels': rels([['rId9', 'hyperlink', 'https://example.com/docs', true], ['rId8', 'notesSlide', '../notesSlides/notesSlide1.xml']]),
+ 'ppt/notesSlides/notesSlide1.xml': ` ${para(run('Say this first.'))} `,
+ });
+}
+
+test('xml-lite keeps nesting, attributes and entities', () => {
+ const root = parseXml(' hi <there> ');
+ assert.equal(root.name, 'a:p');
+ assert.equal(root.attrs.x, '1 & 2');
+ assert.equal(path(root, 'a:r', 'a:t').text, ' hi ');
+ assert.equal(kids(root, 'a:br').length, 1);
+ assert.equal(child(root, 'a:missing'), null);
+});
+
+test('every paragraph becomes a string with a stable id, in document order', async () => {
+ const { strings, warnings } = pptxToHtml(await sample());
+ assert.deepEqual(strings.strings.map((s) => s.id), ['s1.sh2.p1', 's1.sh4.p1', 's1.sh6.p1', 's1.notes.p1']);
+ assert.equal(strings.version, 1);
+ assert.equal(strings.sourceLang, 'en-US');
+ assert.deepEqual(warnings, []);
+});
+
+test('text is kept verbatim and escaped, with only contract tags inline', async () => {
+ const { strings } = pptxToHtml(await sample());
+ const byId = Object.fromEntries(strings.strings.map((s) => [s.id, s]));
+ assert.equal(byId['s1.sh2.p1'].text, 'Hello bold & <tags> end');
+ assert.equal(byId['s1.sh4.p1'].text, 'See docs');
+ assert.equal(byId['s1.notes.p1'].text, 'Say this first.');
+ assert.equal(byId['s1.notes.p1'].kind, 'notes');
+});
+
+test('monospace paragraphs and hidden shapes stay out of the table', async () => {
+ const { html, strings, stats } = pptxToHtml(await sample());
+ assert.ok(!strings.strings.some((s) => s.text.includes('x = 1')));
+ assert.ok(html.includes('x = 1'), 'code is still in the slide');
+ assert.ok(html.includes('data-nt'), 'code paragraphs are marked no-translate');
+ assert.ok(!html.includes('Hidden text'));
+ assert.equal(stats.codeParagraphs, 1);
+});
+
+test('every translatable paragraph carries data-i18n matching the table', async () => {
+ const { html, strings } = pptxToHtml(await sample());
+ for (const s of strings.strings) assert.ok(html.includes(`data-i18n="${s.id}"`), s.id);
+});
+
+test('group rotation reaches the shapes inside it', async () => {
+ const { html } = pptxToHtml(await sample());
+ assert.match(html, /data-id="6"[^>]*transform:rotate\(90deg\)/);
+});
+
+test('slide canvas follows the deck size', async () => {
+ const { html, deck } = pptxToHtml(await sample());
+ assert.deepEqual(deck.canvas, { width: 1280, height: 720 });
+ assert.match(html, /class="pptx-canvas" style="[^"]*width:1280px;height:720px/);
+});
+
+test('preset geometry returns unit paths and falls back to null when unknown', () => {
+ const arrow = presetGeometry('rightArrow', {}, 200, 100);
+ assert.equal(arrow.kind, 'path');
+ assert.ok(arrow.d.startsWith('M'));
+ assert.equal(presetGeometry('roundRect', {}, 200, 100).kind, 'css');
+ assert.equal(presetGeometry('noSuchShape', {}, 200, 100), null);
+});
+
+test('rejects files that are not a pptx', () => {
+ assert.throws(() => pptxToHtml(new Map()), /Is this really a \.pptx/);
+});
diff --git a/tools/build-manifest.mjs b/tools/build-manifest.mjs
index 20d6019..0b1e72d 100644
--- a/tools/build-manifest.mjs
+++ b/tools/build-manifest.mjs
@@ -64,23 +64,25 @@ async function buildManifest() {
}
const deckDir = join(DECKS_DIR, slug);
+ const hasHtml = await exists(join(deckDir, 'slides.html'));
const slidesPath = join(deckDir, 'slides.md');
- if (!(await exists(slidesPath))) {
- warnings.push(`skipped "${slug}": no slides.md`);
+ if (!hasHtml && !(await exists(slidesPath))) {
+ warnings.push(`skipped "${slug}": no slides.md or slides.html`);
continue;
}
- const slides = await readFile(slidesPath, 'utf8');
const meta = await readMeta(deckDir, slug);
+ const title = meta.title || (hasHtml ? slug : titleFromSlides(await readFile(slidesPath, 'utf8'), slug));
decks.push({
slug,
- title: meta.title || titleFromSlides(slides, slug),
+ title,
description: meta.description || '',
author: meta.author || '',
date: meta.date || '',
tags: Array.isArray(meta.tags) ? meta.tags : [],
theme: meta.theme || 'night',
+ ...(hasHtml ? { format: 'html' } : {}),
});
}
diff --git a/tools/build-site.mjs b/tools/build-site.mjs
index 5517b21..acc6019 100644
--- a/tools/build-site.mjs
+++ b/tools/build-site.mjs
@@ -19,6 +19,7 @@
import { readFile, writeFile, mkdir, rm, readdir, stat } from 'node:fs/promises';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { assembleStandaloneHtml } from './standalone-html.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DIST = join(ROOT, 'dist');
@@ -277,6 +278,32 @@ ${usesMermaid ? ` mermaid.initialize({ startOnLoad: false, theme: 'dark'
`;
}
+/**
+ * Standalone build for decks imported from PowerPoint (format "html"): the
+ * slides are already HTML, so they go in as-is with pictures inlined. Any
+ * strings..json files ride along, and ?lang= picks one.
+ */
+async function buildStandaloneHtml(deck, meta, slidesHtml, tables, images, sharedTheme) {
+ const [reveal, theme, revealJs, pptx, i18n] = await Promise.all([
+ fetchAsset(asset.revealCss),
+ fetchAsset(asset.theme(deck.theme || 'white')),
+ fetchAsset(asset.revealJs),
+ readFile(join(ROOT, 'assets/pptx-slide.css'), 'utf8'),
+ readFile(join(ROOT, 'assets/deck-i18n.js'), 'utf8'),
+ ]);
+ return assembleStandaloneHtml({
+ title: deck.title || deck.slug,
+ source: `decks/${deck.slug}/slides.html by tools/build-site.mjs`,
+ lang: meta.lang,
+ canvas: meta.canvas,
+ slidesHtml,
+ images,
+ tables,
+ css: { reveal, theme, shared: sharedTheme, pptx },
+ js: { reveal: revealJs, i18n },
+ });
+}
+
async function main() {
const manifest = JSON.parse(await readFile(join(ROOT, 'decks.json'), 'utf8'));
const decks = manifest.decks ?? [];
@@ -315,7 +342,8 @@ async function main() {
await copyInto('index.html');
await copyInto('decks.json');
await copyInto('assets/theme.css');
-
+ await copyInto('assets/pptx-slide.css');
+ await copyInto('assets/deck-i18n.js');
const deckHtml = await readFile(join(ROOT, 'deck.html'), 'utf8');
const rewritten = deckHtml.replace(
/window\.DECK_ASSET_BASE = '[^']*';/,
@@ -333,6 +361,16 @@ async function main() {
if (importRewritten === importHtml) throw new Error('Could not rewrite DECK_ASSET_BASE in import.html');
await writeFile(join(DIST, 'import.html'), importRewritten, 'utf8');
await copyInto('tools/pptx-core.mjs');
+ for (const file of ['xml-lite', 'pptx-style', 'pptx-geom', 'pptx-html', 'zip-tools', 'standalone-html']) {
+ await copyInto(`tools/${file}.mjs`);
+ }
+ const convertHtml = await readFile(join(ROOT, 'convert.html'), 'utf8');
+ const convertRewritten = convertHtml.replace(
+ /window\.DECK_ASSET_BASE = '[^']*';/,
+ "window.DECK_ASSET_BASE = './vendor';",
+ );
+ if (convertRewritten === convertHtml) throw new Error('Could not rewrite DECK_ASSET_BASE in convert.html');
+ await writeFile(join(DIST, 'convert.html'), convertRewritten, 'utf8');
// 3. Copy each deck's sources, and build its standalone single file.
const sharedTheme = await readFile(join(ROOT, 'assets/theme.css'), 'utf8');
@@ -340,6 +378,36 @@ async function main() {
for (const deck of decks) {
const deckDir = join('decks', deck.slug);
+ const posixDir = deckDir.replace(/\\/g, '/');
+
+ if (deck.format === 'html') {
+ const slidesHtml = await readFile(join(ROOT, deckDir, 'slides.html'), 'utf8');
+ const meta = JSON.parse(await readFile(join(ROOT, deckDir, 'deck.json'), 'utf8'));
+
+ // Everything in the folder ships: slides.html, string tables, images.
+ const assets = await deckAssetFiles(deckDir);
+ for (const file of assets) await copyBinaryInto(`${posixDir}/${file}`);
+ await copyInto(`${posixDir}/deck.json`);
+
+ const tables = {};
+ for (const file of assets) {
+ const m = /^strings\.([A-Za-z0-9-]+)\.json$/.exec(file);
+ if (m) tables[m[1]] = JSON.parse(await readFile(join(ROOT, deckDir, file), 'utf8'));
+ }
+
+ const images = {};
+ for (const file of assets) {
+ const m = /^images\/([^/]+)$/.exec(file);
+ const mime = m && MIME_BY_EXT[m[1].slice(m[1].lastIndexOf('.')).toLowerCase()];
+ if (mime) images[m[1]] = `data:${mime};base64,${(await readFile(join(ROOT, deckDir, file))).toString('base64')}`;
+ }
+
+ const html = await buildStandaloneHtml(deck, meta, slidesHtml, tables, images, sharedTheme);
+ await writeFile(join(DIST, 'standalone', `${deck.slug}.html`), html, 'utf8');
+ console.log(`standalone/${deck.slug}.html ${(Buffer.byteLength(html) / 1024 / 1024).toFixed(1)} MB (html deck, ${Object.keys(tables).length} translation(s))`);
+ continue;
+ }
+
const slides = await readFile(join(ROOT, deckDir, 'slides.md'), 'utf8');
await copyInto(join(deckDir, 'slides.md').replace(/\\/g, '/'));
@@ -385,9 +453,13 @@ async function main() {
' Everything is vendored, so it needs no internet access.',
'',
'import.html',
- ' Convert a PowerPoint file into a deck in the browser. Also needs to be',
+ ' Convert a PowerPoint file into a Markdown deck in the browser. Also needs to be',
' served rather than double-clicked. The file never leaves your machine.',
'',
+ 'convert.html',
+ ' Convert a PowerPoint file into localizable HTML (positioned slides plus a',
+ ' string table), preview it, and download a single HTML file. Also needs a server.',
+ '',
'Arrow keys navigate, S opens speaker notes (served site only),',
'Esc shows the slide overview, ? lists every shortcut.',
'',
diff --git a/tools/pptx-geom.mjs b/tools/pptx-geom.mjs
new file mode 100644
index 0000000..c7f8b41
--- /dev/null
+++ b/tools/pptx-geom.mjs
@@ -0,0 +1,269 @@
+/**
+ * Preset geometry for the PPTX to HTML converter.
+ *
+ * Shapes come back in one of two forms:
+ * { kind: 'css', radius } a rectangle or ellipse the browser can draw itself
+ * { kind: 'path', d, open } a path in unit coordinates (0..1), used as a
+ * clip-path for the fill and as the outline
+ *
+ * Presets are approximations of the ECMA-376 definitions. They cover what real
+ * decks use most; anything else returns null and the caller falls back to a
+ * plain rectangle and says so in the import report.
+ */
+
+const KAPPA = 0.5522847498;
+
+/** Collects path commands in pixel space, then normalizes to the unit square. */
+class Path {
+ constructor() { this.cmds = []; }
+
+ M(x, y) { this.cmds.push(['M', x, y]); return this; }
+
+ L(x, y) { this.cmds.push(['L', x, y]); return this; }
+
+ Q(cx, cy, x, y) { this.cmds.push(['Q', cx, cy, x, y]); return this; }
+
+ C(a, b, c, d, x, y) { this.cmds.push(['C', a, b, c, d, x, y]); return this; }
+
+ Z() { this.cmds.push(['Z']); return this; }
+
+ poly(points, close = true) {
+ points.forEach(([x, y], i) => (i ? this.L(x, y) : this.M(x, y)));
+ return close ? this.Z() : this;
+ }
+
+ /** Normalized to 0..1; `w`/`h` are the shape size in px. */
+ toUnit(w, h) {
+ const f = (n) => Math.round(n * 1e4) / 1e4;
+ return this.cmds.map(([c, ...n]) => c + n.map((v, i) => f(v / (i % 2 === 0 ? w : h))).join(' ')).join(' ');
+ }
+
+ /** Absolute pixel path string. */
+ toPx() {
+ const f = (n) => Math.round(n * 100) / 100;
+ return this.cmds.map(([c, ...n]) => c + n.map(f).join(' ')).join(' ');
+ }
+}
+
+const gd = (adj, name, fallback) => (adj[name] != null ? adj[name] : fallback);
+
+/** Read guides into { adj: 50000, ... }. */
+export function readAdjust(prstGeom) {
+ const out = {};
+ const list = prstGeom?.children.find((c) => c.name === 'a:avLst');
+ for (const g of list?.children ?? []) {
+ const m = /val\s+(-?\d+)/.exec(g.attrs.fmla ?? '');
+ if (m && g.attrs.name) out[g.attrs.name] = Number(m[1]);
+ }
+ return out;
+}
+
+export function presetGeometry(prst, adj, w, h) {
+ const ss = Math.min(w, h);
+ const hw = w / 2;
+ const hh = h / 2;
+ const P = () => new Path();
+ const unit = (p, open = false) => ({ kind: 'path', d: p.toUnit(w, h), open });
+
+ switch (prst) {
+ case 'rect':
+ case 'flowChartProcess':
+ case 'flowChartPredefinedProcess':
+ case 'flowChartInternalStorage':
+ case 'flowChartDocument':
+ case 'flowChartMultidocument':
+ case 'flowChartMagneticDisk':
+ case 'flowChartPunchedCard':
+ return { kind: 'css', radius: null };
+ case 'roundRect':
+ return { kind: 'css', radius: (ss * gd(adj, 'adj', 16667)) / 100000 };
+ case 'flowChartAlternateProcess':
+ return { kind: 'css', radius: ss / 6 };
+ case 'flowChartTerminator':
+ return { kind: 'css', radius: ss / 2 };
+ case 'ellipse':
+ case 'flowChartConnector':
+ return { kind: 'css', radius: '50%' };
+
+ case 'triangle': {
+ const x = (w * gd(adj, 'adj', 50000)) / 100000;
+ return unit(P().poly([[x, 0], [w, h], [0, h]]));
+ }
+ case 'rtTriangle': return unit(P().poly([[0, 0], [0, h], [w, h]]));
+ case 'diamond':
+ case 'flowChartDecision':
+ return unit(P().poly([[hw, 0], [w, hh], [hw, h], [0, hh]]));
+ case 'parallelogram':
+ case 'flowChartInputOutput': {
+ const x = (ss * gd(adj, 'adj', 25000)) / 100000;
+ return unit(P().poly([[0, h], [x, 0], [w, 0], [w - x, h]]));
+ }
+ case 'trapezoid': {
+ const x = (ss * gd(adj, 'adj', 25000)) / 100000;
+ return unit(P().poly([[0, h], [x, 0], [w - x, 0], [w, h]]));
+ }
+ case 'homePlate': {
+ const dx = (ss * gd(adj, 'adj', 50000)) / 100000;
+ return unit(P().poly([[0, 0], [w - dx, 0], [w, hh], [w - dx, h], [0, h]]));
+ }
+ case 'chevron': {
+ const dx = (ss * gd(adj, 'adj', 50000)) / 100000;
+ return unit(P().poly([[0, 0], [w - dx, 0], [w, hh], [w - dx, h], [0, h], [dx, hh]]));
+ }
+ case 'hexagon': {
+ const dx = (ss * gd(adj, 'adj', 25000)) / 100000;
+ return unit(P().poly([[0, hh], [dx, 0], [w - dx, 0], [w, hh], [w - dx, h], [dx, h]]));
+ }
+ case 'octagon': {
+ const d = (ss * gd(adj, 'adj', 29289)) / 100000;
+ return unit(P().poly([[d, 0], [w - d, 0], [w, d], [w, h - d], [w - d, h], [d, h], [0, h - d], [0, d]]));
+ }
+ case 'plus': {
+ const d = (ss * gd(adj, 'adj', 25000)) / 100000;
+ return unit(P().poly([[d, 0], [w - d, 0], [w - d, d], [w, d], [w, h - d], [w - d, h - d], [w - d, h], [d, h], [d, h - d], [0, h - d], [0, d], [d, d]]));
+ }
+
+ case 'rightArrow': {
+ const dx = (ss * gd(adj, 'adj2', 50000)) / 100000;
+ const dy = (h * gd(adj, 'adj1', 50000)) / 200000;
+ const x1 = w - dx;
+ return unit(P().poly([[0, hh - dy], [x1, hh - dy], [x1, 0], [w, hh], [x1, h], [x1, hh + dy], [0, hh + dy]]));
+ }
+ case 'leftArrow': {
+ const dx = (ss * gd(adj, 'adj2', 50000)) / 100000;
+ const dy = (h * gd(adj, 'adj1', 50000)) / 200000;
+ return unit(P().poly([[w, hh - dy], [dx, hh - dy], [dx, 0], [0, hh], [dx, h], [dx, hh + dy], [w, hh + dy]]));
+ }
+ case 'upArrow': {
+ const dy = (ss * gd(adj, 'adj2', 50000)) / 100000;
+ const dx = (w * gd(adj, 'adj1', 50000)) / 200000;
+ return unit(P().poly([[hw, 0], [w, dy], [hw + dx, dy], [hw + dx, h], [hw - dx, h], [hw - dx, dy], [0, dy]]));
+ }
+ case 'downArrow': {
+ const dy = (ss * gd(adj, 'adj2', 50000)) / 100000;
+ const dx = (w * gd(adj, 'adj1', 50000)) / 200000;
+ return unit(P().poly([[hw - dx, 0], [hw + dx, 0], [hw + dx, h - dy], [w, h - dy], [hw, h], [0, h - dy], [hw - dx, h - dy]]));
+ }
+ case 'leftRightArrow': {
+ const dx = (ss * gd(adj, 'adj2', 50000)) / 100000;
+ const dy = (h * gd(adj, 'adj1', 50000)) / 200000;
+ return unit(P().poly([[0, hh], [dx, 0], [dx, hh - dy], [w - dx, hh - dy], [w - dx, 0], [w, hh], [w - dx, h], [w - dx, hh + dy], [dx, hh + dy], [dx, h]]));
+ }
+
+ case 'flowChartOffpageConnector': return unit(P().poly([[0, 0], [w, 0], [w, h * 0.8], [hw, h], [0, h * 0.8]]));
+ case 'flowChartDelay':
+ return unit(P().M(0, 0).L(hw, 0).C(hw + hw * KAPPA, 0, w, hh - hh * KAPPA, w, hh)
+ .C(w, hh + hh * KAPPA, hw + hw * KAPPA, h, hw, h).L(0, h).Z());
+
+ case 'round1Rect': {
+ const r = (ss * gd(adj, 'adj', 16667)) / 100000;
+ return unit(P().M(0, 0).L(w - r, 0).Q(w, 0, w, r).L(w, h).L(0, h).Z());
+ }
+ case 'round2SameRect': {
+ const r1 = (ss * gd(adj, 'adj1', 16667)) / 100000;
+ const r2 = (ss * gd(adj, 'adj2', 0)) / 100000;
+ return unit(P().M(r1, 0).L(w - r1, 0).Q(w, 0, w, r1).L(w, h - r2).Q(w, h, w - r2, h)
+ .L(r2, h).Q(0, h, 0, h - r2).L(0, r1).Q(0, 0, r1, 0).Z());
+ }
+ case 'snip1Rect': {
+ const d = (ss * gd(adj, 'adj', 16667)) / 100000;
+ return unit(P().poly([[0, 0], [w - d, 0], [w, d], [w, h], [0, h]]));
+ }
+ case 'snip2SameRect': {
+ const d1 = (ss * gd(adj, 'adj1', 16667)) / 100000;
+ const d2 = (ss * gd(adj, 'adj2', 0)) / 100000;
+ return unit(P().poly([[d1, 0], [w - d1, 0], [w, d1], [w, h - d2], [w - d2, h], [d2, h], [0, h - d2], [0, d1]]));
+ }
+ case 'cube': {
+ const d = (ss * gd(adj, 'adj', 25000)) / 100000;
+ return unit(P().poly([[0, d], [d, 0], [w, 0], [w, h - d], [w - d, h], [0, h]]));
+ }
+ case 'can': {
+ const d = (h * gd(adj, 'adj', 25000)) / 200000;
+ return unit(P().M(0, d).Q(0, 0, hw, 0).Q(w, 0, w, d).L(w, h - d).Q(w, h, hw, h).Q(0, h, 0, h - d).Z());
+ }
+ case 'star4': {
+ const k = (gd(adj, 'adj', 12500) / 50000) * 0.7071;
+ const ix = hw * k;
+ const iy = hh * k;
+ return unit(P().poly([[0, hh], [hw - ix, hh - iy], [hw, 0], [hw + ix, hh - iy], [w, hh], [hw + ix, hh + iy], [hw, h], [hw - ix, hh + iy]]));
+ }
+
+ case 'rightBrace':
+ case 'leftBrace': {
+ const r = Math.min((ss * gd(adj, 'adj1', 8333)) / 100000, hh);
+ const ym = (h * gd(adj, 'adj2', 50000)) / 100000;
+ const mx = (x) => (prst === 'leftBrace' ? w - x : x);
+ return unit(P().M(mx(0), 0).Q(mx(hw), 0, mx(hw), r).L(mx(hw), ym - r).Q(mx(hw), ym, mx(w), ym)
+ .Q(mx(hw), ym, mx(hw), ym + r).L(mx(hw), h - r).Q(mx(hw), h, mx(0), h), true);
+ }
+ case 'leftBracket': return unit(P().poly([[w, 0], [0, 0], [0, h], [w, h]], false), true);
+ case 'rightBracket': return unit(P().poly([[0, 0], [w, 0], [w, h], [0, h]], false), true);
+
+ case 'wedgeRectCallout':
+ case 'wedgeRoundRectCallout': {
+ const xt = hw + (w * gd(adj, 'adj1', -20833)) / 100000;
+ const yt = hh + (h * gd(adj, 'adj2', 62500)) / 100000;
+ const p = P().M(0, 0);
+ const vertical = Math.abs((yt - hh) / h) >= Math.abs((xt - hw) / w);
+ const left = xt < hw;
+ const above = yt < hh;
+ const x1 = left ? w / 6 : (w * 7) / 12;
+ const x2 = left ? (w * 5) / 12 : (w * 5) / 6;
+ const y1 = above ? h / 6 : (h * 7) / 12;
+ const y2 = above ? (h * 5) / 12 : (h * 5) / 6;
+ if (vertical && above) p.L(x1, 0).L(xt, yt).L(x2, 0);
+ p.L(w, 0);
+ if (!vertical && !left) p.L(w, y1).L(xt, yt).L(w, y2);
+ p.L(w, h);
+ if (vertical && !above) p.L(x2, h).L(xt, yt).L(x1, h);
+ p.L(0, h);
+ if (!vertical && left) p.L(0, y2).L(xt, yt).L(0, y1);
+ return unit(p.Z());
+ }
+ default:
+ return null;
+ }
+}
+
+/**
+ * Connector (and plain line) path in pixel space for a w x h box, drawn from the
+ * top-left to the bottom-right. The caller mirrors it for flipH/flipV.
+ */
+export function connectorPath(prst, adj, w, h) {
+ const a1 = gd(adj, 'adj1', 50000) / 100000;
+ const a2 = gd(adj, 'adj2', 50000) / 100000;
+ const a3 = gd(adj, 'adj3', 50000) / 100000;
+ const p = new Path();
+ switch (prst) {
+ case 'bentConnector2': return p.M(0, 0).L(w, 0).L(w, h).toPx();
+ case 'bentConnector3': return p.M(0, 0).L(w * a1, 0).L(w * a1, h).L(w, h).toPx();
+ case 'bentConnector4': return p.M(0, 0).L(w * a1, 0).L(w * a1, h * a2).L(w, h * a2).L(w, h).toPx();
+ case 'bentConnector5': return p.M(0, 0).L(w * a1, 0).L(w * a1, h * a2).L(w * a3, h * a2).L(w * a3, h).L(w, h).toPx();
+ case 'curvedConnector2': return p.M(0, 0).C(w / 2, 0, w, h / 2, w, h).toPx();
+ case 'curvedConnector3': return p.M(0, 0).C(w * a1, 0, w * a1, h / 2, w * a1, h / 2).C(w * a1, h / 2, w * a1, h, w, h).toPx();
+ default: return p.M(0, 0).L(w, h).toPx();
+ }
+}
+
+/** Unit-square path for a:custGeom, built from its first drawable path(s). */
+export function customGeometry(custGeom, w, h) {
+ const list = custGeom.children.find((c) => c.name === 'a:pathLst');
+ const parts = [];
+ let open = true;
+ for (const path of list?.children ?? []) {
+ if (path.name !== 'a:path' || path.attrs.fill === 'none' && path.attrs.stroke === '0') continue;
+ const pw = Number(path.attrs.w) || w;
+ const ph = Number(path.attrs.h) || h;
+ const pt = (n) => `${Math.round((Number(n.attrs.x) / pw) * 1e4) / 1e4} ${Math.round((Number(n.attrs.y) / ph) * 1e4) / 1e4}`;
+ for (const cmd of path.children) {
+ const pts = cmd.children.filter((c) => c.name === 'a:pt');
+ if (cmd.name === 'a:moveTo') parts.push(`M${pt(pts[0])}`);
+ else if (cmd.name === 'a:lnTo') parts.push(`L${pt(pts[0])}`);
+ else if (cmd.name === 'a:cubicBezTo') parts.push(`C${pts.map(pt).join(' ')}`);
+ else if (cmd.name === 'a:quadBezTo') parts.push(`Q${pts.map(pt).join(' ')}`);
+ else if (cmd.name === 'a:close') { parts.push('Z'); open = false; }
+ }
+ }
+ return parts.length ? { kind: 'path', d: parts.join(' '), open } : null;
+}
diff --git a/tools/pptx-html.mjs b/tools/pptx-html.mjs
new file mode 100644
index 0000000..8191da3
--- /dev/null
+++ b/tools/pptx-html.mjs
@@ -0,0 +1,932 @@
+/**
+ * Converts an inflated .pptx into positioned HTML plus a string table.
+ *
+ * pptxToHtml(files, options) -> { html, strings, images, deck, warnings, stats }
+ *
+ * Every slide becomes a fixed-size canvas (the deck's own size, 1 CSS px per
+ * 9525 EMU) with shapes placed absolutely. All text stays live HTML, and each
+ * paragraph is tagged with data-i18n so it can be swapped for a translation.
+ * See docs/string-table.md for the contract this module writes.
+ *
+ * Like pptx-core.mjs this uses no Node APIs and no dependencies; callers do
+ * their own decompression and pass in the entries as a Map.
+ */
+import { parseXml, child, kids, path, descendants, attrOf } from './xml-lite.mjs';
+import {
+ emuToPx, ptToPx, px, readColor, colorToCss, readTheme, readFill, styleFill, readLine,
+ readRunProps, readParaProps, mergeProps, levelProps, fontStack, fillFromElement, MONO_FONT,
+} from './pptx-style.mjs';
+import {
+ presetGeometry, connectorPath, customGeometry, readAdjust,
+} from './pptx-geom.mjs';
+
+const decoder = new TextDecoder('utf-8');
+const num = (v, d = 0) => (v == null || v === '' || Number.isNaN(Number(v)) ? d : Number(v));
+const truthy = (v) => v === '1' || v === 'true';
+const esc = (s) => s.replace(/&/g, '&').replace(//g, '>');
+const escAttr = (s) => esc(s).replace(/"/g, '"');
+const round = (n) => Math.round(n * 100) / 100;
+
+/** Inline style string from an object, skipping empty values. */
+const style = (o) => Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k}:${v}`).join(';');
+
+/* ----------------------------------------------------------- package ---- */
+
+function resolvePath(baseDir, target) {
+ if (target.startsWith('/')) return target.slice(1);
+ const stack = [];
+ for (const part of `${baseDir}/${target}`.split('/')) {
+ if (part === '..') stack.pop();
+ else if (part && part !== '.') stack.push(part);
+ }
+ return stack.join('/');
+}
+
+function openPackage(files) {
+ const xmlCache = new Map();
+ const relCache = new Map();
+ const text = (name) => (files.has(name) ? decoder.decode(files.get(name)) : '');
+ const xml = (name) => {
+ if (!files.has(name)) return null;
+ if (!xmlCache.has(name)) xmlCache.set(name, parseXml(text(name)));
+ return xmlCache.get(name);
+ };
+ const rels = (part) => {
+ if (relCache.has(part)) return relCache.get(part);
+ const dir = part.split('/').slice(0, -1).join('/');
+ const map = new Map();
+ const root = xml(`${dir}/_rels/${part.split('/').pop()}.rels`);
+ for (const r of kids(root, 'Relationship')) {
+ const external = r.attrs.TargetMode === 'External';
+ map.set(r.attrs.Id, {
+ type: r.attrs.Type ?? '',
+ external,
+ target: external ? r.attrs.Target : resolvePath(dir, r.attrs.Target ?? ''),
+ });
+ }
+ relCache.set(part, map);
+ return map;
+ };
+ return { files, text, xml, rels };
+}
+
+const relOfType = (rels, suffix) => [...rels.values()].find((r) => r.type.endsWith(suffix))?.target ?? null;
+
+/* ------------------------------------------------------ master/layout ---- */
+
+const phType = (t) => (t === 'ctrTitle' ? 'title' : ['subTitle', 'obj', 'body', 'chart', 'tbl', 'media', 'pic'].includes(t) ? 'body' : t);
+
+function readPh(node) {
+ const nv = child(node, node.name === 'p:pic' ? 'p:nvPicPr' : 'p:nvSpPr');
+ const ph = path(nv, 'p:nvPr', 'p:ph');
+ return ph ? { type: ph.attrs.type ?? 'body', idx: ph.attrs.idx ?? null } : null;
+}
+
+function placeholdersOf(tree) {
+ const out = [];
+ for (const n of tree?.children ?? []) {
+ if (n.name !== 'p:sp' && n.name !== 'p:pic') continue;
+ const ph = readPh(n);
+ if (ph) out.push({ ...ph, node: n });
+ }
+ return out;
+}
+
+function buildChain(pkg, slidePath, cache) {
+ const slideRels = pkg.rels(slidePath);
+ const layoutPath = relOfType(slideRels, '/slideLayout');
+ if (cache.has(layoutPath)) return cache.get(layoutPath);
+
+ const layoutXml = layoutPath ? pkg.xml(layoutPath) : null;
+ const layoutRels = layoutPath ? pkg.rels(layoutPath) : new Map();
+ const masterPath = relOfType(layoutRels, '/slideMaster');
+ const masterXml = masterPath ? pkg.xml(masterPath) : null;
+ const masterRels = masterPath ? pkg.rels(masterPath) : new Map();
+ const themePath = relOfType(masterRels, '/theme');
+ const theme = readTheme(themePath ? pkg.xml(themePath) : null);
+
+ const clrMap = { ...(child(masterXml, 'p:clrMap')?.attrs ?? {}) };
+ const override = child(child(layoutXml, 'p:clrMapOvr'), 'a:overrideClrMapping');
+ if (override) Object.assign(clrMap, override.attrs);
+
+ const layoutTree = path(layoutXml, 'p:cSld', 'p:spTree');
+ const masterTree = path(masterXml, 'p:cSld', 'p:spTree');
+ const chain = {
+ layoutPath, masterPath, layoutXml, masterXml, layoutRels, masterRels, theme, clrMap,
+ env: { theme, clrMap },
+ layoutTree,
+ masterTree,
+ layoutPhs: placeholdersOf(layoutTree),
+ masterPhs: placeholdersOf(masterTree),
+ txStyles: child(masterXml, 'p:txStyles'),
+ };
+ cache.set(layoutPath, chain);
+ return chain;
+}
+
+/** The layout and master placeholder a slide placeholder inherits from. */
+function inheritFor(chain, ph) {
+ if (!ph) return [];
+ const sameType = (m) => phType(m.type) === phType(ph.type);
+ const layout = (ph.idx != null ? chain.layoutPhs.find((m) => m.idx === ph.idx) : null)
+ ?? chain.layoutPhs.find(sameType) ?? null;
+ const master = chain.masterPhs.find((m) => phType(m.type) === phType(layout?.type ?? ph.type)) ?? null;
+ return [layout?.node, master?.node].filter(Boolean);
+}
+
+/* ------------------------------------------------------------ helpers ---- */
+
+function readXfrm(node) {
+ const x = node && (node.name === 'a:xfrm' || node.name === 'p:xfrm' ? node : child(node, 'a:xfrm'));
+ const off = child(x, 'a:off');
+ const ext = child(x, 'a:ext');
+ if (!x || !off || !ext) return null;
+ const chOff = child(x, 'a:chOff');
+ const chExt = child(x, 'a:chExt');
+ return {
+ x: num(off.attrs.x), y: num(off.attrs.y), cx: num(ext.attrs.cx), cy: num(ext.attrs.cy),
+ rot: num(x.attrs.rot) / 60000, flipH: truthy(x.attrs.flipH), flipV: truthy(x.attrs.flipV),
+ chOff: chOff ? { x: num(chOff.attrs.x), y: num(chOff.attrs.y) } : null,
+ chExt: chExt ? { cx: num(chExt.attrs.cx), cy: num(chExt.attrs.cy) } : null,
+ };
+}
+
+/*
+ * Transforms are 2D affine matrices in EMU: X = a*x + c*y + e, Y = b*x + d*y + f.
+ * Groups compose one over the other, so nested groups, scaling, flips and
+ * rotation all fall out of the same arithmetic.
+ */
+const IDENTITY = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
+
+const mul = (P, Q) => ({
+ a: P.a * Q.a + P.c * Q.b, b: P.b * Q.a + P.d * Q.b,
+ c: P.a * Q.c + P.c * Q.d, d: P.b * Q.c + P.d * Q.d,
+ e: P.a * Q.e + P.c * Q.f + P.e, f: P.b * Q.e + P.d * Q.f + P.f,
+});
+
+const scaleX = (T) => Math.hypot(T.a, T.b);
+const scaleY = (T) => Math.hypot(T.c, T.d);
+const translate = (T, x, y) => mul(T, { a: 1, b: 0, c: 0, d: 1, e: x, f: y });
+
+/** Where a shape's box lands on the slide, in px, with its net rotation and flips. */
+function place(xf, T) {
+ const cx = xf.x + xf.cx / 2;
+ const cy = xf.y + xf.cy / 2;
+ const centerX = T.a * cx + T.c * cy + T.e;
+ const centerY = T.b * cx + T.d * cy + T.f;
+ const w = emuToPx(xf.cx * scaleX(T));
+ const h = emuToPx(xf.cy * scaleY(T));
+ const mirrored = T.a * T.d - T.b * T.c < 0;
+ const turn = mirrored ? Math.atan2(-T.b, -T.a) : Math.atan2(T.b, T.a);
+ return {
+ x: emuToPx(centerX) - w / 2,
+ y: emuToPx(centerY) - h / 2,
+ w,
+ h,
+ rot: xf.rot + (turn * 180) / Math.PI,
+ flipH: xf.flipH !== mirrored,
+ flipV: xf.flipV,
+ };
+}
+
+function composeGroup(T, xf) {
+ const sx = xf.chExt?.cx ? xf.cx / xf.chExt.cx : 1;
+ const sy = xf.chExt?.cy ? xf.cy / xf.chExt.cy : 1;
+ const into = { a: sx, b: 0, c: 0, d: sy, e: xf.x - sx * (xf.chOff?.x ?? 0), f: xf.y - sy * (xf.chOff?.y ?? 0) };
+ const cx = xf.x + xf.cx / 2;
+ const cy = xf.y + xf.cy / 2;
+ const fx = xf.flipH ? -1 : 1;
+ const fy = xf.flipV ? -1 : 1;
+ const flip = { a: fx, b: 0, c: 0, d: fy, e: cx * (1 - fx), f: cy * (1 - fy) };
+ const th = (xf.rot * Math.PI) / 180;
+ const cos = Math.cos(th);
+ const sin = Math.sin(th);
+ const turn = { a: cos, b: sin, c: -sin, d: cos, e: cx - cos * cx + sin * cy, f: cy - sin * cx - cos * cy };
+ return mul(T, mul(turn, mul(flip, into)));
+}
+
+const firstOf = (list, fn) => { for (const item of list) { const v = fn(item); if (v != null) return v; } return null; };
+
+function rotFlip(box, flip = false) {
+ const parts = [];
+ if (box.rot) parts.push(`rotate(${round(box.rot)}deg)`);
+ if (flip && (box.flipH || box.flipV)) parts.push(`scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})`);
+ return parts.length ? parts.join(' ') : null;
+}
+
+function warn(S, message) {
+ const text = `slide ${S.slideNo}: ${message}`;
+ if (!S.warnings.includes(text)) S.warnings.push(text);
+}
+
+function imageRef(S, zipPath) {
+ if (!S.images.has(zipPath)) {
+ let name = zipPath.split('/').pop();
+ const taken = new Set(S.images.values());
+ let n = 1;
+ const base = name;
+ while (taken.has(name)) name = `${++n}-${base}`;
+ S.images.set(zipPath, name);
+ }
+ return `images/${S.images.get(zipPath)}`;
+}
+
+function bgCss(fill, S, part) {
+ if (!fill) return null;
+ if (fill.type === 'solid' || fill.type === 'grad') return { background: fill.css };
+ if (fill.type === 'pic') {
+ const target = part.rels.get(fill.relId)?.target;
+ if (!target || !S.pkg.files.has(target)) return null;
+ if (/\.(emf|wmf)$/i.test(target)) { warn(S, 'EMF/WMF picture fill not rendered'); return null; }
+ return { 'background-image': `url(${imageRef(S, target)})`, 'background-size': fill.tile ? 'auto' : '100% 100%', 'background-repeat': fill.tile ? 'repeat' : 'no-repeat' };
+ }
+ return null;
+}
+
+const DASH = { dash: 'dashed', sysDash: 'dashed', lgDash: 'dashed', dashDot: 'dashed', lgDashDot: 'dashed', sysDot: 'dotted', dot: 'dotted', sysDashDot: 'dashed', sysDashDotDot: 'dashed' };
+
+/* --------------------------------------------------------------- text ---- */
+
+const WINGDINGS = {
+ '§': '▪', '\uF0A7': '▪', Ø: '➢', '\uF0D8': '➢', ü: '✓', '\uF0FC': '✓',
+ l: '●', '\uF06C': '●', n: '■', '\uF06E': '■', v: '❖', '\uF076': '❖', q: '❑', u: '◆', '\uF0B7': '•',
+};
+const SYMBOLISH = /wingdings|symbol/i;
+
+function toRoman(n) {
+ const map = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']];
+ let out = '';
+ let rest = n;
+ for (const [v, s] of map) while (rest >= v) { out += s; rest -= v; }
+ return out;
+}
+
+function autoNumber(n, scheme) {
+ const alpha = (v) => { let s = ''; let k = v; while (k > 0) { s = String.fromCharCode(97 + ((k - 1) % 26)) + s; k = Math.floor((k - 1) / 26); } return s; };
+ const core = /^alpha/.test(scheme) ? alpha(n) : /^roman/.test(scheme) ? toRoman(n) : String(n);
+ const text = /Uc/.test(scheme) ? core.toUpperCase() : core;
+ if (/ParenBoth$/.test(scheme)) return `(${text})`;
+ if (/ParenR$/.test(scheme)) return `${text})`;
+ return `${text}.`;
+}
+
+/** Resolve a typeface (including +mj-lt/+mn-lt) to a plain name. */
+const faceName = (face, theme) => (face === '+mj-lt' ? theme.major : face === '+mn-lt' ? theme.minor : face);
+const isMono = (rp, theme) => MONO_FONT.test(faceName(rp.font, theme) ?? '');
+
+function safeHref(S, part, relId) {
+ const rel = relId ? part.rels.get(relId) : null;
+ if (!rel?.external) return null;
+ return /^(https?:|mailto:)/i.test(rel.target) ? rel.target : null;
+}
+
+/**
+ * One paragraph -> { html, string }. `string` is the inline HTML that goes in
+ * the table (null for blank or code paragraphs, which are not translated).
+ */
+function renderParagraph(p, pIndex, tx) {
+ const { S, part, env } = tx;
+ const pPr = child(p, 'a:pPr');
+ const level = Math.min(8, num(pPr?.attrs.lvl, 0));
+ const props = mergeProps(tx.baseProps(level), readParaProps(pPr, env));
+
+ const runs = [];
+ for (const c of p.children) {
+ if (c.name === 'a:br') runs.push({ br: true });
+ else if (c.name === 'a:r' || c.name === 'a:fld') {
+ const t = child(c, 'a:t')?.text ?? '';
+ if (t !== '') runs.push({ text: t, rp: { ...props.r, ...readRunProps(child(c, 'a:rPr'), env) } });
+ }
+ }
+ const textRuns = runs.filter((r) => !r.br);
+ const plain = textRuns.map((r) => r.text).join('');
+ const sizeOf = (rp) => (rp.sz ?? 1800) * tx.fontScale;
+
+ const common = {
+ 'text-align': { l: 'left', ctr: 'center', r: 'right', just: 'justify' }[props.algn] ?? null,
+ };
+ const lnScale = 1 - tx.lnReduce;
+ common['line-height'] = props.lnSpc?.pct ? round(props.lnSpc.pct * 1.2 * lnScale) : props.lnSpc?.pt ? px(ptToPx(props.lnSpc.pt)) : lnScale !== 1 ? round(1.2 * lnScale) : null;
+ const space = (s) => (s?.pct != null ? `${round(s.pct * 1.2)}em` : s?.pt != null ? px(ptToPx(s.pt)) : null);
+ common['margin-top'] = space(props.spcBef);
+ common['margin-bottom'] = space(props.spcAft);
+
+ if (!plain.trim()) {
+ tx.numbering = {};
+ const rp = { ...props.r, ...readRunProps(child(p, 'a:endParaRPr'), env) };
+ return { html: ``, string: null };
+ }
+
+ const dominant = textRuns.reduce((a, b) => (b.text.length > a.text.length ? b : a), textRuns[0]).rp;
+ const codeParagraph = textRuns.filter((r) => r.text.trim()).every((r) => isMono(r.rp, env.theme));
+
+ // A paragraph is bold, italic or underlined as a whole when every run agrees;
+ // when they differ, the inherited style is the baseline and the exceptions
+ // become tags. Colors and sizes come from the dominant run, so the table
+ // stays translator-friendly.
+ const visible = textRuns.filter((r) => r.text.trim());
+ const base = {};
+ for (const k of ['b', 'i', 'u']) {
+ const values = new Set(visible.map((r) => Boolean(r.rp[k])));
+ base[k] = values.size === 1 ? [...values][0] : Boolean(props.r?.[k]);
+ }
+
+ const segs = [];
+ for (const r of runs) {
+ if (r.br) { segs.push({ br: true }); continue; }
+ const tags = [];
+ const href = safeHref(S, part, r.rp.link);
+ if (!codeParagraph && isMono(r.rp, env.theme)) tags.push('code');
+ if (r.rp.b && !base.b) tags.push('b');
+ if (r.rp.i && !base.i) tags.push('i');
+ if (r.rp.u && !base.u) tags.push('u');
+ if (r.rp.baseline > 0) tags.push('sup');
+ else if (r.rp.baseline < 0) tags.push('sub');
+ const key = `${tags.join(',')}|${href ?? ''}`;
+ const last = segs[segs.length - 1];
+ if (last && !last.br && last.key === key) last.text += r.text;
+ else segs.push({ key, tags, href, text: r.text });
+ }
+ const inline = segs.map((s) => {
+ if (s.br) return '
';
+ let out = esc(s.text);
+ for (const t of [...s.tags].reverse()) out = `<${t}>${out}${t}>`;
+ return s.href ? `${out}` : out;
+ }).join('');
+
+ // Bullets and indentation.
+ let marL = props.marL ?? 0;
+ let indent = props.indent ?? 0;
+ const bu = props.bu ?? { type: 'none' };
+ let bullet = null;
+ if (bu.type === 'char') {
+ const raw = bu.char ?? '•';
+ bullet = SYMBOLISH.test(props.buFont ?? '') ? (WINGDINGS[raw] ?? '•') : raw;
+ } else if (bu.type === 'auto') {
+ const n = (tx.numbering[level] ?? bu.startAt - 1) + 1;
+ tx.numbering[level] = n;
+ bullet = autoNumber(n, bu.scheme);
+ }
+ for (const k of Object.keys(tx.numbering)) if (Number(k) > level) delete tx.numbering[k];
+ if (bu.type !== 'auto') delete tx.numbering[level];
+ if (bullet && indent >= 0) { indent = -285750; marL += 285750; }
+
+ const mono = codeParagraph;
+ const font = dominant.font ? fontStack(dominant.font, env.theme) : null;
+ const css = {
+ ...common,
+ 'font-size': `calc(var(--fit,1) * ${px(ptToPx(sizeOf(dominant) / 100))})`,
+ color: dominant.color,
+ 'font-family': mono ? (font ?? "Consolas, 'Cascadia Mono', monospace") : font,
+ 'font-weight': base.b ? 700 : null,
+ 'font-style': base.i ? 'italic' : null,
+ 'text-decoration': [base.u ? 'underline' : null, dominant.strike ? 'line-through' : null].filter(Boolean).join(' ') || null,
+ 'text-transform': dominant.cap === 'all' ? 'uppercase' : null,
+ 'letter-spacing': dominant.spc ? px(ptToPx(dominant.spc / 100)) : null,
+ 'padding-left': marL ? px(emuToPx(marL)) : null,
+ 'text-indent': !bullet && indent ? px(emuToPx(indent)) : null,
+ };
+ let attrs = '';
+ if (bullet) {
+ css['--bl'] = px(emuToPx(marL + indent));
+ if (props.buColor) css['--buc'] = props.buColor;
+ if (props.buSize) css['--bus'] = `${round(props.buSize * 100)}%`;
+ attrs = ` data-bu="${escAttr(bullet)}"`;
+ }
+
+ let string = null;
+ let id = '';
+ if (!codeParagraph) {
+ id = `${tx.idPrefix}.p${pIndex}`;
+ string = { id, kind: tx.kind, text: inline };
+ } else S.stats.codeParagraphs += 1;
+
+ const idAttr = string ? ` data-i18n="${id}"` : ' data-nt';
+ return { html: `${inline}
`, string };
+}
+
+/** Merge bodyPr attributes from the placeholder chain (master -> layout -> slide). */
+function bodyPrOf(txBody, inh) {
+ const own = child(txBody, 'a:bodyPr');
+ const chain = [...inh].reverse().map((n) => child(child(n, 'p:txBody'), 'a:bodyPr')).filter(Boolean);
+ if (own) chain.push(own);
+ const attrs = Object.assign({}, ...chain.map((b) => b.attrs));
+ const fit = [...chain].reverse().map((b) => b.children.find((c) => c.name.startsWith('a:') && /AutoFit|noAutofit/i.test(c.name))).find(Boolean);
+ return { attrs, fit };
+}
+
+/** Paragraphs of a text body -> HTML for the .tx container (and string pushes). */
+function renderTextBody(txBody, S, part, opts) {
+ const { env } = S.chain;
+ const ph = opts.ph ?? null;
+ const inh = opts.inh ?? [];
+ const body = bodyPrOf(txBody, inh);
+ const fontScale = body.fit?.name === 'a:normAutofit' ? num(body.fit.attrs.fontScale, 100000) / 100000 : 1;
+ const lnReduce = body.fit?.name === 'a:normAutofit' ? num(body.fit.attrs.lnSpcReduction, 0) / 100000 : 0;
+ const fontRef = readColor(child(opts.styleNode, 'a:fontRef'), env);
+
+ const baseProps = (level) => {
+ let p = levelProps(S.pres.defaultTextStyle, level, env);
+ if (ph) {
+ const key = phType(ph.type) === 'title' ? 'p:titleStyle' : phType(ph.type) === 'body' ? 'p:bodyStyle' : 'p:otherStyle';
+ p = mergeProps(p, levelProps(child(S.chain.txStyles, key), level, env));
+ }
+ if (fontRef) p = mergeProps(p, { r: { color: colorToCss(fontRef) } });
+ for (const n of [...inh].reverse()) p = mergeProps(p, levelProps(child(child(n, 'p:txBody'), 'a:lstStyle'), level, env));
+ if (opts.extraLevels) p = mergeProps(p, opts.extraLevels(level));
+ return mergeProps(p, levelProps(child(txBody, 'a:lstStyle'), level, env));
+ };
+
+ const tx = { S, part, env, baseProps, fontScale, lnReduce, numbering: {}, idPrefix: opts.idPrefix, kind: opts.kind ?? 'text' };
+ const paragraphs = kids(txBody, 'a:p');
+ const out = [];
+ paragraphs.forEach((p, i) => {
+ const { html, string } = renderParagraph(p, i + 1, tx);
+ out.push(html);
+ if (string) S.addString(string);
+ });
+ return { html: out.join(''), body, hasText: paragraphs.some((p) => descendants(p, 'a:t').some((t) => t.text.trim())) };
+}
+
+function txContainerStyle(body, box) {
+ const a = body.attrs;
+ const anchor = { t: 'flex-start', ctr: 'center', b: 'flex-end' }[a.anchor] ?? 'flex-start';
+ const css = {
+ padding: [num(a.tIns, 45720), num(a.rIns, 91440), num(a.bIns, 45720), num(a.lIns, 91440)].map((v) => px(emuToPx(v))).join(' '),
+ 'justify-content': anchor,
+ 'min-height': px(box.h),
+ 'white-space': a.wrap === 'none' ? 'pre' : null,
+ };
+ if (a.vert === 'vert' || a.vert === 'eaVert') css['writing-mode'] = 'vertical-rl';
+ else if (a.vert === 'vert270') { css['writing-mode'] = 'vertical-rl'; css.transform = 'rotate(180deg)'; }
+ return style(css);
+}
+
+/* ------------------------------------------------------------- shapes ---- */
+
+function geometryOf(holders, box, S) {
+ const prstGeom = firstOf(holders, (h) => child(h, 'a:prstGeom'));
+ const cust = firstOf(holders, (h) => child(h, 'a:custGeom'));
+ if (prstGeom) {
+ const prst = prstGeom.attrs.prst ?? 'rect';
+ const geo = presetGeometry(prst, readAdjust(prstGeom), box.w, box.h);
+ if (!geo) { S.unknown.set(prst, (S.unknown.get(prst) ?? 0) + 1); return { kind: 'css', radius: null, prst }; }
+ return { ...geo, prst };
+ }
+ if (cust) {
+ const geo = customGeometry(cust, box.w, box.h);
+ if (geo) return { ...geo, prst: 'custGeom' };
+ }
+ return { kind: 'css', radius: null, prst: 'rect' };
+}
+
+function lineCss(ln) {
+ if (!ln) return null;
+ return `${px(Math.max(1, ln.w))} ${DASH[ln.dash] ?? 'solid'} ${ln.color}`;
+}
+
+function clipRef(S, d) {
+ let id = S.clips.get(d);
+ if (!id) {
+ id = `cp${S.slideNo}-${S.clips.size + 1}`;
+ S.clips.set(d, id);
+ S.defs.push(` `);
+ }
+ return id;
+}
+
+function renderSp(sp, S, part, T) {
+ const cNv = path(sp, 'p:nvSpPr', 'p:cNvPr');
+ if (truthy(cNv?.attrs.hidden)) return '';
+ const id = cNv?.attrs.id ?? '0';
+ const ph = readPh(sp);
+ if (part.decor && ph) return '';
+ const spPr = child(sp, 'p:spPr');
+ const styleNode = child(sp, 'p:style');
+ const txBody = child(sp, 'p:txBody');
+ const inh = part.decor ? [] : inheritFor(S.chain, ph);
+ const holders = [spPr, ...inh.map((n) => child(n, 'p:spPr'))].filter(Boolean);
+ const env = S.chain.env;
+
+ const prst = child(spPr, 'a:prstGeom')?.attrs.prst;
+ const hasText = txBody && descendants(txBody, 'a:t').some((t) => t.text.trim());
+ if (prst && /^(line|straightConnector1|bentConnector\d|curvedConnector\d)$/.test(prst) && !hasText) {
+ return renderConnector(sp, S, part, T);
+ }
+
+ const xf = firstOf([spPr, ...inh.map((n) => child(n, 'p:spPr'))], readXfrm);
+ let box;
+ if (xf) box = place(xf, T);
+ else {
+ box = { x: 0, y: 0, w: S.width, h: 0 };
+ warn(S, `shape ${id} has no position; placed at the top-left`);
+ }
+
+ const geo = geometryOf(holders, box, S);
+ let fill = firstOf(holders, (h) => readFill(h, env));
+ if (!fill && styleNode) fill = styleFill(styleNode, env);
+ const lnHolder = holders.find((h) => child(h, 'a:ln')) ?? spPr;
+ const ln = readLine(lnHolder, styleNode, env);
+
+ const lineBox = txBody ? renderTextBody(txBody, S, part, {
+ ph, inh, styleNode, idPrefix: `${part.scope}.sh${id}`, kind: 'text',
+ }) : null;
+
+ const bg = bgCss(fill, S, part);
+ const hasBg = Boolean(bg) || (ln && geo.kind === 'css');
+ if (!lineBox?.hasText && !bg && !ln) return '';
+ S.stats.shapes += 1;
+
+ const flipT = xf && (box.flipH || box.flipV) ? `scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})` : null;
+ const parts = [];
+ if (geo.kind === 'css') {
+ if (hasBg) {
+ parts.push(``);
+ }
+ } else {
+ if (bg && !geo.open) parts.push(``);
+ if (ln) {
+ const dash = DASH[ln.dash] ? `stroke-dasharray="${ln.dash.includes('ot') || ln.dash === 'sysDot' ? '1 3' : '6 4'}"` : '';
+ parts.push(``);
+ }
+ }
+ if (lineBox?.hasText || lineBox?.html) parts.push(`${lineBox.html}`);
+
+ const shapeStyle = style({
+ left: px(box.x), top: px(box.y), width: px(box.w), 'min-height': px(box.h), transform: xf ? rotFlip(box) : null,
+ });
+ return `${parts.join('')}`;
+}
+
+function markerDefs(S, id, ln, which, end) {
+ const kind = end.type === 'oval' ? 'oval' : end.type === 'diamond' ? 'diamond' : 'arrow';
+ const size = { sm: 2, med: 3, lg: 5 };
+ const mw = size[end.w] ?? 3;
+ const mh = size[end.len] ?? 3;
+ const shape = kind === 'oval' ? ' ' : kind === 'diamond' ? ' ' : ' ';
+ return `${shape} `;
+}
+
+function renderConnector(node, S, part, T) {
+ const isSp = node.name === 'p:sp';
+ const cNv = path(node, isSp ? 'p:nvSpPr' : 'p:nvCxnSpPr', 'p:cNvPr');
+ if (truthy(cNv?.attrs.hidden)) return '';
+ const id = cNv?.attrs.id ?? '0';
+ const spPr = child(node, 'p:spPr');
+ const xf = readXfrm(spPr);
+ const ln = readLine(spPr, child(node, 'p:style'), S.chain.env);
+ if (!xf || !ln) return '';
+ const box = place(xf, T);
+ const prstGeom = child(spPr, 'a:prstGeom');
+ const w = Math.max(box.w, 0.01);
+ const h = Math.max(box.h, 0.01);
+ const d = connectorPath(prstGeom?.attrs.prst ?? 'line', readAdjust(prstGeom), w, h);
+ S.stats.connectors += 1;
+
+ const defs = [];
+ let startM = '';
+ let endM = '';
+ if (ln.head) { const mid = `mk${S.slideNo}-${S.scopeKey}${id}h`; defs.push(markerDefs(S, mid, ln, 'h', ln.head)); startM = ` marker-start="url(#${mid})"`; }
+ if (ln.tail) { const mid = `mk${S.slideNo}-${S.scopeKey}${id}t`; defs.push(markerDefs(S, mid, ln, 't', ln.tail)); endM = ` marker-end="url(#${mid})"`; }
+ const dash = DASH[ln.dash] ? ` stroke-dasharray="${/ot/i.test(ln.dash) ? '1 3' : '6 4'}"` : '';
+ const transform = [box.rot ? `rotate(${round(box.rot)}deg)` : null, box.flipH || box.flipV ? `scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})` : null].filter(Boolean).join(' ');
+ const css = style({ left: px(box.x), top: px(box.y), width: px(w), height: px(h), transform: transform || null });
+ return ``;
+}
+
+function renderPic(pic, S, part, T) {
+ const cNv = path(pic, 'p:nvPicPr', 'p:cNvPr');
+ if (truthy(cNv?.attrs.hidden)) return '';
+ const id = cNv?.attrs.id ?? '0';
+ const ph = readPh(pic);
+ if (part.decor && ph) return '';
+ const inh = part.decor ? [] : inheritFor(S.chain, ph);
+ const spPr = child(pic, 'p:spPr');
+ const xf = firstOf([spPr, ...inh.map((n) => child(n, 'p:spPr'))], readXfrm);
+ if (!xf) return '';
+ const box = place(xf, T);
+
+ const blipFill = child(pic, 'p:blipFill');
+ const blip = child(blipFill, 'a:blip');
+ const svgBlip = descendants(blip ?? { children: [] }, 'asvg:svgBlip')[0];
+ const relId = svgBlip?.attrs['r:embed'] ?? blip?.attrs['r:embed'];
+ const target = relId ? part.rels.get(relId)?.target : null;
+ if (!target || !S.pkg.files.has(target)) return '';
+
+ const env = S.chain.env;
+ const geo = presetGeometry(child(spPr, 'a:prstGeom')?.attrs.prst ?? 'rect', readAdjust(child(spPr, 'a:prstGeom')), box.w, box.h);
+ const radius = geo?.kind === 'css' ? (geo.radius === '50%' ? '50%' : geo.radius ? px(geo.radius) : null) : null;
+ const ln = readLine(spPr, null, env);
+ S.stats.pictures += 1;
+
+ const outer = style({
+ left: px(box.x), top: px(box.y), width: px(box.w), height: px(box.h), transform: rotFlip(box), 'border-radius': radius, border: lineCss(ln),
+ });
+ if (/\.(emf|wmf)$/i.test(target)) {
+ warn(S, `picture ${id} is EMF/WMF and cannot be shown in a browser`);
+ return ``;
+ }
+
+ const crop = child(blipFill, 'a:srcRect');
+ const l = num(crop?.attrs.l) / 100000; const t = num(crop?.attrs.t) / 100000;
+ const r = num(crop?.attrs.r) / 100000; const b = num(crop?.attrs.b) / 100000;
+ const vw = 1 - l - r || 1; const vh = 1 - t - b || 1;
+ const imgCss = crop ? style({
+ width: `${round(100 / vw)}%`, height: `${round(100 / vh)}%`, left: `${round((-l / vw) * 100)}%`, top: `${round((-t / vh) * 100)}%`,
+ }) : '';
+ const flip = box.flipH || box.flipV ? `transform:scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})` : '';
+
+ const descr = cNv?.attrs.descr?.trim();
+ let altAttr = ' alt=""';
+ if (descr) {
+ const altId = `${part.scope}.sh${id}.alt`;
+ S.addString({ id: altId, kind: 'alt', text: esc(descr) });
+ altAttr = ` alt="${escAttr(descr)}" data-i18n-alt="${altId}"`;
+ }
+ return `
`;
+}
+
+/* ------------------------------------------------------------- tables ---- */
+
+function tableStyleOf(S, styleId) {
+ if (!styleId) return null;
+ if (!S.tableStyles) S.tableStyles = S.pkg.xml('ppt/tableStyles.xml');
+ return kids(S.tableStyles, 'a:tblStyle').find((s) => s.attrs.styleId === styleId) ?? null;
+}
+
+function renderTable(frame, tbl, S, part, T) {
+ const xf = readXfrm(child(frame, 'p:xfrm'));
+ if (!xf) return '';
+ const box = place(xf, T);
+ const env = S.chain.env;
+ const id = path(frame, 'p:nvGraphicFramePr', 'p:cNvPr')?.attrs.id ?? '0';
+ const tblPr = child(tbl, 'a:tblPr');
+ const tstyle = tableStyleOf(S, child(tblPr, 'a:tableStyleId')?.text.trim());
+ const firstRow = truthy(tblPr?.attrs.firstRow);
+ const bandRow = truthy(tblPr?.attrs.bandRow);
+
+ const part2 = (name) => child(tstyle, name);
+ const cellStyle = (rowIdx) => {
+ const picks = [part2('wholeTbl'), bandRow ? part2(rowIdx % 2 === (firstRow ? 1 : 0) ? 'band1H' : 'band2H') : null, firstRow && rowIdx === 0 ? part2('firstRow') : null].filter(Boolean);
+ let fill = null; let color = null; let bold = false;
+ for (const p of picks) {
+ const f = readFill(child(child(p, 'a:tcStyle'), 'a:fill'), env);
+ if (f) fill = f;
+ const tx = child(p, 'a:tcTxStyle');
+ const c = readColor(tx, env);
+ if (c) color = colorToCss(c);
+ if (tx?.attrs.b === 'on') bold = true;
+ }
+ return { fill, color, bold };
+ };
+
+ const cols = kids(child(tbl, 'a:tblGrid'), 'a:gridCol').map((c) => emuToPx(num(c.attrs.w) * scaleX(T)));
+ const cells = [];
+ const rows = kids(tbl, 'a:tr').map((tr, r) => {
+ const cs = cellStyle(r);
+ const tds = kids(tr, 'a:tc').map((tc, c) => {
+ if (truthy(tc.attrs.hMerge) || truthy(tc.attrs.vMerge)) return '';
+ const tcPr = child(tc, 'a:tcPr');
+ const txBody = child(tc, 'a:txBody');
+ const content = txBody ? renderTextBody(txBody, S, part, {
+ idPrefix: `${part.scope}.sh${id}.r${r + 1}c${c + 1}`,
+ kind: 'table',
+ extraLevels: () => ({ r: { ...(cs.color ? { color: cs.color } : {}), ...(cs.bold || (firstRow && r === 0) ? { b: true } : {}) } }),
+ }) : { html: '' };
+ const own = readFill(tcPr, env) ?? cs.fill;
+ const border = (name) => { const l = readLine(child(tcPr, name) ? { children: [child(tcPr, name)] } : null, null, env); return l ? lineCss(l) : null; };
+ const css = style({
+ ...(bgCss(own, S, part) ?? {}),
+ 'vertical-align': { ctr: 'middle', b: 'bottom' }[tcPr?.attrs.anchor] ?? 'top',
+ padding: [num(tcPr?.attrs.marT, 45720), num(tcPr?.attrs.marR, 91440), num(tcPr?.attrs.marB, 45720), num(tcPr?.attrs.marL, 91440)].map((v) => px(emuToPx(v))).join(' '),
+ 'border-left': border('a:lnL'), 'border-right': border('a:lnR'), 'border-top': border('a:lnT'), 'border-bottom': border('a:lnB'),
+ });
+ const span = `${num(tc.attrs.gridSpan, 1) > 1 ? ` colspan="${tc.attrs.gridSpan}"` : ''}${num(tc.attrs.rowSpan, 1) > 1 ? ` rowspan="${tc.attrs.rowSpan}"` : ''}`;
+ return `${content.html} `;
+ });
+ cells.push(tds.length);
+ return `${tds.join('')} `;
+ });
+ S.stats.tables += 1;
+ return `${cols.map((w) => ` `).join('')} ${rows.join('')}
`;
+}
+
+/* --------------------------------------------------- frames and groups ---- */
+
+function renderFrame(frame, S, part, T) {
+ const graphic = path(frame, 'a:graphic', 'a:graphicData');
+ const tbl = child(graphic, 'a:tbl');
+ if (tbl) return renderTable(frame, tbl, S, part, T);
+
+ const uri = graphic?.attrs.uri ?? '';
+ const xf = readXfrm(child(frame, 'p:xfrm'));
+ const box = xf ? place(xf, T) : null;
+ const ghost = (label) => (box ? `` : '');
+
+ if (/diagram/.test(uri) && box) {
+ const dm = child(graphic, 'dgm:relIds');
+ const drawing = [...part.rels.values()].find((r) => r.type.endsWith('/diagramDrawing'))?.target;
+ if (drawing && dm) {
+ // The drawing part holds SmartArt already laid out as plain shapes.
+ const raw = S.pkg.text(drawing).replace(/<(\/?)dsp:/g, '<$1p:');
+ const tree = parseXml(raw);
+ const inner = path(tree, 'p:spTree');
+ if (inner) {
+ S.stats.smartart += 1;
+ return renderTree(inner, S, part, translate(T, xf.x, xf.y));
+ }
+ }
+ warn(S, 'SmartArt could not be rendered');
+ return ghost('SmartArt not rendered');
+ }
+ if (/chart/.test(uri)) { warn(S, 'chart not rendered (placeholder shown)'); return ghost('Chart not rendered'); }
+ if (!/ole|table/i.test(uri)) warn(S, `graphic frame (${uri.split('/').pop() || 'unknown'}) not rendered`);
+ return ghost('Content not rendered');
+}
+
+function renderTree(tree, S, part, T) {
+ const out = [];
+ const visit = (node, t) => {
+ switch (node.name) {
+ case 'p:sp': out.push(renderSp(node, S, part, t)); break;
+ case 'p:pic': out.push(renderPic(node, S, part, t)); break;
+ case 'p:cxnSp': out.push(renderConnector(node, S, part, t)); break;
+ case 'p:graphicFrame': out.push(renderFrame(node, S, part, t)); break;
+ case 'p:grpSp': {
+ if (truthy(path(node, 'p:nvGrpSpPr', 'p:cNvPr')?.attrs.hidden)) break;
+ const xf = readXfrm(child(node, 'p:grpSpPr'));
+ S.stats.groups += 1;
+ const inner = xf ? composeGroup(t, xf) : t;
+ node.children.forEach((c) => visit(c, inner));
+ break;
+ }
+ case 'mc:AlternateContent': {
+ const pick = child(node, 'mc:Fallback') ?? child(node, 'mc:Choice');
+ pick?.children.forEach((c) => visit(c, t));
+ break;
+ }
+ default: break;
+ }
+ };
+ tree.children.forEach((c) => visit(c, T));
+ return out.filter(Boolean).join('\n');
+}
+
+/* ---------------------------------------------------- backgrounds, notes ---- */
+
+function backgroundOf(S, sources) {
+ const env = S.chain.env;
+ for (const { xml, part } of sources) {
+ const bg = path(xml, 'p:cSld', 'p:bg');
+ if (!bg) continue;
+ const bgPr = child(bg, 'p:bgPr');
+ if (bgPr) {
+ const css = bgCss(readFill(bgPr, env), S, part);
+ if (css) return css;
+ }
+ const ref = child(bg, 'p:bgRef');
+ if (ref) {
+ const phClr = readColor(ref, env);
+ const idx = num(ref.attrs.idx);
+ const entry = S.chain.theme.bgFills[idx - 1001];
+ const fill = entry ? fillFromElement(entry, { ...env, phClr }) : phClr ? { type: 'solid', css: colorToCss(phClr) } : null;
+ const css = bgCss(fill, S, part);
+ if (css) return css;
+ }
+ }
+ return { background: '#fff' };
+}
+
+function renderNotes(S, slidePath, slideRels) {
+ const notesPath = relOfType(slideRels, '/notesSlide');
+ const notesXml = notesPath ? S.pkg.xml(notesPath) : null;
+ const tree = path(notesXml, 'p:cSld', 'p:spTree');
+ if (!tree) return '';
+ const part = { rels: S.pkg.rels(notesPath), scope: `s${S.slideNo}.notes` };
+ const out = [];
+ for (const sp of kids(tree, 'p:sp')) {
+ if (readPh(sp)?.type !== 'body') continue;
+ const txBody = child(sp, 'p:txBody');
+ if (!txBody) continue;
+ kids(txBody, 'a:p').forEach((p, i) => {
+ const tx = {
+ S, part, env: S.chain.env, baseProps: () => ({}), fontScale: 1, lnReduce: 0, numbering: {}, idPrefix: part.scope, kind: 'notes',
+ };
+ const { html, string } = renderParagraph(p, i + 1, tx);
+ if (string) { S.addString(string); out.push(html.replace(/ style="[^"]*"/, '')); }
+ });
+ }
+ return out.length ? `` : '';
+}
+
+/* --------------------------------------------------------------- entry ---- */
+
+export function pptxToHtml(files, options = {}) {
+ const pkg = openPackage(files);
+ const presXml = pkg.xml('ppt/presentation.xml');
+ if (!presXml) throw new Error('No slides found. Is this really a .pptx?');
+ const presRels = pkg.rels('ppt/presentation.xml');
+ const slidePaths = kids(child(presXml, 'p:sldIdLst'), 'p:sldId')
+ .map((s) => presRels.get(s.attrs['r:id'])?.target)
+ .filter((p) => p && files.has(p));
+ if (!slidePaths.length) throw new Error('No slides found. Is this really a .pptx?');
+
+ const sz = child(presXml, 'p:sldSz');
+ const width = Math.round(emuToPx(num(sz?.attrs.cx, 12192000)));
+ const height = Math.round(emuToPx(num(sz?.attrs.cy, 6858000)));
+
+ const langCounts = {};
+ const warnings = [];
+ const strings = [];
+ const images = new Map();
+ const unknown = new Map();
+ const stats = { slides: 0, shapes: 0, pictures: 0, groups: 0, connectors: 0, tables: 0, smartart: 0, codeParagraphs: 0, strings: 0 };
+ const chainCache = new Map();
+ const sections = [];
+
+ slidePaths.forEach((slidePath, index) => {
+ const slideNo = index + 1;
+ const sld = pkg.xml(slidePath);
+ for (const m of pkg.text(slidePath).matchAll(/\blang="([A-Za-z-]+)"/g)) langCounts[m[1]] = (langCounts[m[1]] ?? 0) + 1;
+
+ const chain = buildChain(pkg, slidePath, chainCache);
+ const S = {
+ pkg, chain, slideNo, width, height, warnings, images, unknown, stats, strings,
+ pres: { defaultTextStyle: child(presXml, 'p:defaultTextStyle') },
+ defs: [], clips: new Map(), title: '', scopeKey: '',
+ addString(s) {
+ const entry = { id: s.id, slide: slideNo, kind: s.kind, text: s.text };
+ if (this.title && this.title !== s.text) entry.context = this.title;
+ strings.push(entry);
+ },
+ };
+
+ const tree = path(sld, 'p:cSld', 'p:spTree');
+ const titleSp = kids(tree, 'p:sp').find((sp) => ['title', 'ctrTitle'].includes(readPh(sp)?.type));
+ S.title = descendants(titleSp ?? { children: [] }, 'a:t').map((t) => t.text).join('').trim();
+
+ const slideRels = pkg.rels(slidePath);
+ const slidePart = { rels: slideRels, scope: `s${slideNo}`, decor: false };
+ const showLayout = sld.attrs.showMasterSp !== '0';
+ const showMaster = showLayout && chain.layoutXml?.attrs.showMasterSp !== '0';
+
+ const html = [];
+ if (showMaster && chain.masterTree) {
+ S.scopeKey = 'M';
+ html.push(renderTree(chain.masterTree, S, { rels: chain.masterRels, scope: `s${slideNo}.M`, decor: true }, IDENTITY));
+ }
+ if (showLayout && chain.layoutTree) {
+ S.scopeKey = 'L';
+ html.push(renderTree(chain.layoutTree, S, { rels: chain.layoutRels, scope: `s${slideNo}.L`, decor: true }, IDENTITY));
+ }
+ S.scopeKey = '';
+ if (tree) html.push(renderTree(tree, S, slidePart, IDENTITY));
+
+ const bg = backgroundOf(S, [
+ { xml: sld, part: slidePart },
+ { xml: chain.layoutXml, part: { rels: chain.layoutRels } },
+ { xml: chain.masterXml, part: { rels: chain.masterRels } },
+ ]);
+ const hl = chain.theme.colors.hlink ? `#${chain.theme.colors.hlink}` : null;
+ const notes = renderNotes(S, slidePath, slideRels);
+ const defs = S.defs.length ? `` : '';
+ // Hidden slides stay visible here so every string can be reviewed and translated.
+ const hidden = sld.attrs.show === '0' ? ' data-hidden-slide="true"' : '';
+ if (hidden) warn(S, 'hidden in the original deck (shown here)');
+ sections.push(``);
+ stats.slides += 1;
+ });
+
+ for (const [prst, count] of unknown) warnings.push(`${count} shape(s) use preset "${prst}", drawn as a rectangle`);
+ stats.strings = strings.length;
+
+ const sourceLang = Object.entries(langCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'en';
+ const core = pkg.xml('docProps/core.xml');
+ const coreTitle = child(core, 'dc:title')?.text.trim();
+ const coreAuthor = child(core, 'dc:creator')?.text.trim();
+
+ return {
+ html: `${sections.join('\n')}\n`,
+ strings: { version: 1, sourceLang, strings },
+ images: [...images].map(([zipPath, name]) => ({ zipPath, name })),
+ deck: {
+ title: options.title || coreTitle || options.slug || 'Imported deck',
+ description: options.description ?? 'Imported from PowerPoint.',
+ author: options.author ?? coreAuthor ?? '',
+ date: options.date ?? new Date().toISOString().slice(0, 10),
+ tags: options.tags ?? [],
+ theme: options.theme ?? 'white',
+ format: 'html',
+ lang: sourceLang,
+ canvas: { width, height },
+ },
+ warnings,
+ stats,
+ };
+}
diff --git a/tools/pptx-style.mjs b/tools/pptx-style.mjs
new file mode 100644
index 0000000..420bc2f
--- /dev/null
+++ b/tools/pptx-style.mjs
@@ -0,0 +1,333 @@
+/**
+ * Colors, fills, lines, themes and text-style resolution for the PPTX to HTML
+ * converter. Everything here is pure and runs in Node and the browser.
+ */
+import { child, kids, attrOf } from './xml-lite.mjs';
+
+export const EMU_PER_PX = 9525;
+export const emuToPx = (v) => Number(v) / EMU_PER_PX;
+/** Points to CSS pixels at 96 dpi. */
+export const ptToPx = (pt) => (Number(pt) * 96) / 72;
+const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
+export const px = (n) => `${round(n)}px`;
+
+/* ------------------------------------------------------------- color ---- */
+
+const clamp = (v, lo = 0, hi = 255) => Math.min(hi, Math.max(lo, v));
+
+function rgbToHsl({ r, g, b }) {
+ const rn = r / 255; const gn = g / 255; const bn = b / 255;
+ const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn);
+ const l = (max + min) / 2;
+ if (max === min) return { h: 0, s: 0, l };
+ const d = max - min;
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ let h;
+ if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
+ else if (max === gn) h = (bn - rn) / d + 2;
+ else h = (rn - gn) / d + 4;
+ return { h: h / 6, s, l };
+}
+
+function hslToRgb({ h, s, l }) {
+ if (s === 0) { const v = Math.round(l * 255); return { r: v, g: v, b: v }; }
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+ const f = (t) => {
+ let x = t;
+ if (x < 0) x += 1;
+ if (x > 1) x -= 1;
+ if (x < 1 / 6) return p + (q - p) * 6 * x;
+ if (x < 1 / 2) return q;
+ if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
+ return p;
+ };
+ return { r: Math.round(f(h + 1 / 3) * 255), g: Math.round(f(h) * 255), b: Math.round(f(h - 1 / 3) * 255) };
+}
+
+const hexToRgb = (hex) => {
+ const h = hex.replace('#', '');
+ const v = h.length === 3 ? [...h].map((c) => c + c).join('') : h.padEnd(6, '0');
+ return { r: parseInt(v.slice(0, 2), 16), g: parseInt(v.slice(2, 4), 16), b: parseInt(v.slice(4, 6), 16) };
+};
+
+const PRESET_COLORS = {
+ black: '000000', white: 'ffffff', red: 'ff0000', green: '008000', blue: '0000ff', yellow: 'ffff00',
+ gray: '808080', grey: '808080', orange: 'ffa500', purple: '800080', cyan: '00ffff', magenta: 'ff00ff',
+ darkBlue: '00008b', darkGray: 'a9a9a9', lightGray: 'd3d3d3', silver: 'c0c0c0',
+};
+
+const DEFAULT_CLR_MAP = {
+ bg1: 'lt1', tx1: 'dk1', bg2: 'lt2', tx2: 'dk2',
+ accent1: 'accent1', accent2: 'accent2', accent3: 'accent3', accent4: 'accent4', accent5: 'accent5', accent6: 'accent6',
+ hlink: 'hlink', folHlink: 'folHlink',
+};
+
+const COLOR_ELEMENTS = new Set(['a:srgbClr', 'a:schemeClr', 'a:sysClr', 'a:prstClr', 'a:scrgbClr']);
+
+/**
+ * Resolve a color element (or a parent holding one) to {r,g,b,a}.
+ * `env` carries the theme, the colour map and the current placeholder colour.
+ */
+export function readColor(node, env) {
+ if (!node) return null;
+ const el = COLOR_ELEMENTS.has(node.name) ? node : node.children.find((c) => COLOR_ELEMENTS.has(c.name));
+ if (!el) return null;
+
+ let rgb = null;
+ const val = attrOf(el, 'val');
+ if (el.name === 'a:srgbClr') rgb = hexToRgb(val ?? '000000');
+ else if (el.name === 'a:sysClr') rgb = hexToRgb(attrOf(el, 'lastClr') ?? (val === 'window' ? 'ffffff' : '000000'));
+ else if (el.name === 'a:prstClr') rgb = hexToRgb(PRESET_COLORS[val] ?? '000000');
+ else if (el.name === 'a:scrgbClr') {
+ const ch = (n) => Math.round(clamp((Number(attrOf(el, n, 0)) / 100000) * 255));
+ rgb = { r: ch('r'), g: ch('g'), b: ch('b') };
+ } else if (el.name === 'a:schemeClr') {
+ if (val === 'phClr') rgb = env.phClr ? { ...env.phClr } : null;
+ else {
+ const mapped = (env.clrMap ?? DEFAULT_CLR_MAP)[val] ?? val;
+ const hex = env.theme?.colors?.[mapped] ?? env.theme?.colors?.[val];
+ rgb = hex ? hexToRgb(hex) : null;
+ }
+ }
+ if (!rgb) return null;
+
+ let color = { ...rgb, a: 1 };
+ for (const t of el.children) {
+ const v = Number(attrOf(t, 'val', 0)) / 100000;
+ if (t.name === 'a:alpha') color.a = v;
+ else if (['a:lumMod', 'a:lumOff', 'a:satMod', 'a:satOff'].includes(t.name)) {
+ const hsl = rgbToHsl(color);
+ if (t.name === 'a:lumMod') hsl.l = clamp(hsl.l * v, 0, 1);
+ else if (t.name === 'a:lumOff') hsl.l = clamp(hsl.l + v, 0, 1);
+ else if (t.name === 'a:satMod') hsl.s = clamp(hsl.s * v, 0, 1);
+ else hsl.s = clamp(hsl.s + v, 0, 1);
+ color = { ...hslToRgb(hsl), a: color.a };
+ } else if (t.name === 'a:tint') {
+ color = { r: Math.round(color.r * v + 255 * (1 - v)), g: Math.round(color.g * v + 255 * (1 - v)), b: Math.round(color.b * v + 255 * (1 - v)), a: color.a };
+ } else if (t.name === 'a:shade') {
+ color = { r: Math.round(color.r * v), g: Math.round(color.g * v), b: Math.round(color.b * v), a: color.a };
+ }
+ }
+ return color;
+}
+
+export const colorToCss = (c) => (c.a < 1
+ ? `rgba(${c.r},${c.g},${c.b},${round(c.a, 3)})`
+ : `#${[c.r, c.g, c.b].map((n) => n.toString(16).padStart(2, '0')).join('')}`);
+
+/* ------------------------------------------------------------- theme ---- */
+
+export function readTheme(themeRoot) {
+ const theme = { colors: {}, major: null, minor: null, fills: [], bgFills: [], lines: [] };
+ if (!themeRoot) return theme;
+ const elements = child(themeRoot, 'a:themeElements');
+ const scheme = child(elements, 'a:clrScheme');
+ for (const c of scheme?.children ?? []) {
+ const inner = c.children[0];
+ if (!inner) continue;
+ const hex = inner.name === 'a:sysClr' ? attrOf(inner, 'lastClr', '000000') : attrOf(inner, 'val', '000000');
+ theme.colors[c.name.replace('a:', '')] = hex;
+ }
+ const fonts = child(elements, 'a:fontScheme');
+ theme.major = attrOf(child(child(fonts, 'a:majorFont'), 'a:latin'), 'typeface');
+ theme.minor = attrOf(child(child(fonts, 'a:minorFont'), 'a:latin'), 'typeface');
+ const fmt = child(elements, 'a:fmtScheme');
+ theme.fills = child(fmt, 'a:fillStyleLst')?.children ?? [];
+ theme.bgFills = child(fmt, 'a:bgFillStyleLst')?.children ?? [];
+ theme.lines = child(fmt, 'a:lnStyleLst')?.children ?? [];
+ return theme;
+}
+
+/* -------------------------------------------------------------- fill ---- */
+
+const FILL_NAMES = ['a:noFill', 'a:solidFill', 'a:gradFill', 'a:blipFill', 'a:pattFill', 'a:grpFill'];
+
+/** Turn one fill element into { type, css?, relId? }. */
+export function fillFromElement(el, env) {
+ if (!el) return null;
+ if (el.name === 'a:noFill') return { type: 'none' };
+ if (el.name === 'a:solidFill') {
+ const c = readColor(el, env);
+ return c ? { type: 'solid', css: colorToCss(c) } : null;
+ }
+ if (el.name === 'a:gradFill') {
+ const stops = (child(el, 'a:gsLst')?.children ?? [])
+ .map((gs) => ({ pos: Number(attrOf(gs, 'pos', 0)) / 1000, color: readColor(gs, env) }))
+ .filter((s) => s.color)
+ .sort((a, b) => a.pos - b.pos);
+ if (!stops.length) return null;
+ if (stops.length === 1) return { type: 'solid', css: colorToCss(stops[0].color) };
+ const list = stops.map((s) => `${colorToCss(s.color)} ${round(s.pos, 1)}%`).join(', ');
+ const lin = child(el, 'a:lin');
+ if (child(el, 'a:path')) return { type: 'grad', css: `radial-gradient(circle at center, ${list})` };
+ const angle = lin ? Number(attrOf(lin, 'ang', 0)) / 60000 : 0;
+ return { type: 'grad', css: `linear-gradient(${round(angle + 90, 1)}deg, ${list})` };
+ }
+ if (el.name === 'a:blipFill') {
+ const blip = child(el, 'a:blip');
+ return { type: 'pic', relId: attrOf(blip, 'r:embed'), tile: Boolean(child(el, 'a:tile')) };
+ }
+ if (el.name === 'a:pattFill') {
+ const fg = readColor(child(el, 'a:fgClr'), env);
+ return fg ? { type: 'solid', css: colorToCss(fg) } : null;
+ }
+ return null;
+}
+
+/** First fill child of a shape-properties-like element, or null if unspecified. */
+export function readFill(parent, env) {
+ const el = parent?.children.find((c) => FILL_NAMES.includes(c.name));
+ return el ? fillFromElement(el, env) : null;
+}
+
+/** Fill implied by a shape's , using the theme's fill list. */
+export function styleFill(styleNode, env) {
+ const ref = child(styleNode, 'a:fillRef');
+ if (!ref) return null;
+ const idx = Number(attrOf(ref, 'idx', 0));
+ if (idx === 0) return { type: 'none' };
+ const phClr = readColor(ref, env);
+ const list = idx >= 1001 ? env.theme?.bgFills : env.theme?.fills;
+ const entry = list?.[(idx >= 1001 ? idx - 1001 : idx - 1)];
+ if (!entry) return phClr ? { type: 'solid', css: colorToCss(phClr) } : null;
+ return fillFromElement(entry, { ...env, phClr });
+}
+
+/* -------------------------------------------------------------- line ---- */
+
+/** Resolve a shape outline to { w (px), color, dash, head, tail } or null. */
+export function readLine(spPr, styleNode, env) {
+ const ln = child(spPr, 'a:ln');
+ const ref = child(styleNode, 'a:lnRef');
+ const refIdx = Number(attrOf(ref, 'idx', 0));
+ const themeLine = refIdx > 0 ? env.theme?.lines?.[refIdx - 1] : null;
+
+ let w = ln && attrOf(ln, 'w') != null ? Number(attrOf(ln, 'w')) : null;
+ if (w == null && themeLine) w = Number(attrOf(themeLine, 'w', 12700));
+ if (w == null) w = 12700;
+
+ let fill = ln ? readFill(ln, env) : null;
+ if (!fill && ref && refIdx > 0) {
+ const phClr = readColor(ref, env);
+ fill = phClr ? { type: 'solid', css: colorToCss(phClr) } : null;
+ }
+ if (!fill || fill.type === 'none' || fill.type === 'pic') return null;
+ const color = fill.type === 'solid' ? fill.css : '#000';
+
+ const end = (name) => {
+ const e = child(ln, name);
+ const type = attrOf(e, 'type', 'none');
+ return type === 'none' ? null : { type, w: attrOf(e, 'w', 'med'), len: attrOf(e, 'len', 'med') };
+ };
+ return {
+ w: Math.max(0.5, emuToPx(w)),
+ color,
+ dash: attrOf(child(ln, 'a:prstDash'), 'val', 'solid'),
+ head: end('a:headEnd'),
+ tail: end('a:tailEnd'),
+ };
+}
+
+/* ------------------------------------------------------- text styles ---- */
+
+const truthy = (v) => v === '1' || v === 'true';
+
+/** Run properties from an a:rPr / a:defRPr / a:endParaRPr element. */
+export function readRunProps(node, env) {
+ const out = {};
+ if (!node) return out;
+ const a = node.attrs;
+ if (a.sz) out.sz = Number(a.sz);
+ if (a.b != null) out.b = truthy(a.b);
+ if (a.i != null) out.i = truthy(a.i);
+ if (a.u != null) out.u = a.u !== 'none';
+ if (a.strike != null) out.strike = a.strike !== 'noStrike';
+ if (a.baseline != null) out.baseline = Number(a.baseline);
+ if (a.cap != null) out.cap = a.cap;
+ if (a.spc != null) out.spc = Number(a.spc);
+ const fill = readFill(node, env);
+ if (fill?.type === 'solid') out.color = fill.css;
+ const latin = child(node, 'a:latin');
+ if (latin?.attrs.typeface) out.font = latin.attrs.typeface;
+ const ea = child(node, 'a:ea');
+ if (ea?.attrs.typeface) out.eaFont = ea.attrs.typeface;
+ const link = child(node, 'a:hlinkClick');
+ if (link?.attrs['r:id']) out.link = link.attrs['r:id'];
+ return out;
+}
+
+const spacing = (node) => {
+ const pct = child(node, 'a:spcPct');
+ if (pct) return { pct: Number(pct.attrs.val) / 100000 };
+ const pts = child(node, 'a:spcPts');
+ if (pts) return { pt: Number(pts.attrs.val) / 100 };
+ return undefined;
+};
+
+/** Paragraph properties from an a:lvlNpPr / a:pPr element. */
+export function readParaProps(node, env) {
+ const out = {};
+ if (!node) return out;
+ const a = node.attrs;
+ if (a.algn) out.algn = a.algn;
+ if (a.marL != null) out.marL = Number(a.marL);
+ if (a.indent != null) out.indent = Number(a.indent);
+ if (a.rtl != null) out.rtl = truthy(a.rtl);
+ const ln = child(node, 'a:lnSpc');
+ if (ln) out.lnSpc = spacing(ln);
+ const bef = child(node, 'a:spcBef');
+ if (bef) out.spcBef = spacing(bef);
+ const aft = child(node, 'a:spcAft');
+ if (aft) out.spcAft = spacing(aft);
+
+ if (child(node, 'a:buNone')) out.bu = { type: 'none' };
+ else if (child(node, 'a:buChar')) out.bu = { type: 'char', char: attrOf(child(node, 'a:buChar'), 'char', '•') };
+ else if (child(node, 'a:buAutoNum')) {
+ const n = child(node, 'a:buAutoNum');
+ out.bu = { type: 'auto', scheme: attrOf(n, 'type', 'arabicPeriod'), startAt: Number(attrOf(n, 'startAt', 1)) };
+ } else if (child(node, 'a:buBlip')) out.bu = { type: 'char', char: '•' };
+
+ const buFont = child(node, 'a:buFont');
+ if (buFont?.attrs.typeface) out.buFont = buFont.attrs.typeface;
+ const buClr = child(node, 'a:buClr');
+ if (buClr) { const c = readColor(buClr, env); if (c) out.buColor = colorToCss(c); }
+ const buSz = child(node, 'a:buSzPct');
+ if (buSz) out.buSize = Number(buSz.attrs.val) / 100000;
+
+ const def = child(node, 'a:defRPr');
+ if (def) out.r = readRunProps(def, env);
+ return out;
+}
+
+/** Overlay `over` on `base`, merging the nested run-props object. */
+export function mergeProps(base, over) {
+ if (!over) return base;
+ const out = { ...base };
+ for (const [k, v] of Object.entries(over)) {
+ if (v === undefined) continue;
+ out[k] = k === 'r' ? { ...(base.r ?? {}), ...v } : v;
+ }
+ return out;
+}
+
+/** Level N (0-based) properties from an a:lstStyle-like element. */
+export function levelProps(listStyle, level, env) {
+ return readParaProps(child(listStyle, `a:lvl${level + 1}pPr`), env);
+}
+
+/* ------------------------------------------------------------- fonts ---- */
+
+export const MONO_FONT = /^(cascadia|consolas|courier|menlo|monaco|lucida console|fira (code|mono)|source code pro|jetbrains mono|sf mono|roboto mono|ibm plex mono|ubuntu mono|dejavu sans mono|liberation mono)/i;
+
+const quote = (name) => `'${name.replace(/'/g, '')}'`;
+
+/** A CSS font stack for a PPTX typeface, resolving theme fonts. */
+export function fontStack(typeface, theme) {
+ let name = typeface;
+ if (name === '+mj-lt') name = theme?.major;
+ else if (name === '+mn-lt') name = theme?.minor;
+ if (!name) return null;
+ if (MONO_FONT.test(name)) return `${quote(name)}, Consolas, 'Cascadia Mono', Menlo, monospace`;
+ return `${quote(name)}, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif`;
+}
diff --git a/tools/pptx-to-html.mjs b/tools/pptx-to-html.mjs
new file mode 100644
index 0000000..36127dd
--- /dev/null
+++ b/tools/pptx-to-html.mjs
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+/**
+ * Converts a .pptx into a positioned-HTML deck with a localization string table.
+ *
+ * node tools/pptx-to-html.mjs "C:\path\to\talk.pptx" --slug my-talk
+ *
+ * Writes decks//{slides.html, strings.json, deck.json, images/}. Run
+ * `node tools/build-manifest.mjs` afterwards to list it on the landing page.
+ * Requires Node 18+. No dependencies.
+ */
+import { readFileSync } from 'node:fs';
+import { mkdir, writeFile, rm, access } from 'node:fs/promises';
+import { inflateRawSync } from 'node:zlib';
+import { basename, dirname, join, resolve, extname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { listZipEntries, slugify, SLUG_PATTERN } from './pptx-core.mjs';
+import { pptxToHtml } from './pptx-html.mjs';
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+function inflateAll(bytes) {
+ const files = new Map();
+ for (const entry of listZipEntries(bytes)) {
+ const raw = bytes.subarray(entry.dataStart, entry.dataStart + entry.compSize);
+ if (entry.method === 0) files.set(entry.name, raw);
+ else if (entry.method === 8) files.set(entry.name, inflateRawSync(raw));
+ else throw new Error(`Unsupported compression (method ${entry.method}) for ${entry.name}`);
+ }
+ return files;
+}
+
+const exists = (p) => access(p).then(() => true, () => false);
+
+function parseArgs(argv) {
+ const opts = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--force') opts.force = true;
+ else if (a === '--no-images') opts.noImages = true;
+ else if (a.startsWith('--')) opts[a.slice(2)] = argv[++i];
+ else opts._.push(a);
+ }
+ return opts;
+}
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ const file = opts._[0];
+ if (!file) {
+ console.error('Usage: node tools/pptx-to-html.mjs [--slug name] [--out dir] [--title t] [--force]');
+ process.exit(1);
+ }
+
+ const slug = opts.slug ?? slugify(basename(file, extname(file)));
+ if (!SLUG_PATTERN.test(slug)) throw new Error(`"${slug}" is not a valid slug.`);
+ const outDir = resolve(opts.out ?? join(ROOT, 'decks', slug));
+ if (await exists(outDir) && !opts.force) throw new Error(`${outDir} already exists. Pass --force to overwrite it.`);
+
+ const started = Date.now();
+ const files = inflateAll(new Uint8Array(readFileSync(file)));
+ const result = pptxToHtml(files, {
+ slug, title: opts.title, author: opts.author, date: opts.date, theme: opts.theme,
+ description: opts.description ?? `Imported from ${basename(file)}.`,
+ });
+
+ await rm(outDir, { recursive: true, force: true });
+ await mkdir(join(outDir, 'images'), { recursive: true });
+ await writeFile(join(outDir, 'slides.html'), result.html, 'utf8');
+ await writeFile(join(outDir, 'strings.json'), `${JSON.stringify(result.strings, null, 2)}\n`, 'utf8');
+ await writeFile(join(outDir, 'deck.json'), `${JSON.stringify(result.deck, null, 2)}\n`, 'utf8');
+ if (!opts.noImages) {
+ for (const { zipPath, name } of result.images) await writeFile(join(outDir, 'images', name), files.get(zipPath));
+ }
+
+ const { stats } = result;
+ console.log(`Wrote ${outDir} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
+ console.log(` ${stats.slides} slides, ${stats.strings} strings, ${stats.shapes} shapes, ${stats.pictures} pictures, ${stats.connectors} connectors, ${stats.tables} tables, ${stats.codeParagraphs} code paragraphs`);
+ if (result.warnings.length) {
+ console.log(` ${result.warnings.length} warning(s):`);
+ for (const w of result.warnings.slice(0, 40)) console.log(` - ${w}`);
+ if (result.warnings.length > 40) console.log(` ... and ${result.warnings.length - 40} more`);
+ }
+}
+
+main().catch((error) => { console.error(error.message); process.exit(1); });
diff --git a/tools/standalone-html.mjs b/tools/standalone-html.mjs
new file mode 100644
index 0000000..b2b32ed
--- /dev/null
+++ b/tools/standalone-html.mjs
@@ -0,0 +1,109 @@
+/**
+ * Assembles one self-contained HTML file for a deck converted from PowerPoint.
+ *
+ * Pure string work with no Node APIs, so build-site.mjs and the browser
+ * converter (convert.html) produce the same file. Callers supply the Reveal,
+ * theme and slide assets already read into strings.
+ */
+
+const escapeHtml = (value) =>
+ String(value ?? '').replace(/[&<>"']/g, (ch) => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
+ })[ch]);
+
+/** Reveal's themes import Google Fonts; assets/theme.css replaces the fonts anyway. */
+export const stripRemoteFonts = (css) =>
+ css.replace(/@import\s+url\(https?:\/\/fonts\.googleapis\.com[^)]*\);?/g, '');
+
+/**
+ * @param {object} o
+ * @param {string} o.title
+ * @param {string} [o.source] where it was generated from, for the header comment
+ * @param {string} [o.lang] source language tag
+ * @param {{width:number,height:number}} [o.canvas]
+ * @param {string} o.slidesHtml slides.html as written by the converter (pictures as images/)
+ * @param {Record} [o.images] picture name -> data URI; embedded once each
+ * @param {Record} [o.tables] translation tables keyed by language
+ * @param {{reveal:string, theme:string, shared:string, pptx:string}} o.css
+ * @param {{reveal:string, i18n:string}} o.js
+ */
+export function assembleStandaloneHtml(o) {
+ const canvas = o.canvas ?? { width: 1280, height: 720 };
+ // `<` is escaped so no string in a table can close the script element early.
+ const json = JSON.stringify(o.tables ?? {}).replace(/ {
+ if (!(name in available)) return m;
+ used.add(name);
+ return ` data-img="${escapeHtml(name)}"`;
+ })
+ .replace(/url\(images\/([^)"']+)\)/g, (m, name) => {
+ if (!(name in available)) return m;
+ used.add(name);
+ if (!cssVars.has(name)) cssVars.set(name, `--img-${cssVars.size}`);
+ return `var(${cssVars.get(name)})`;
+ });
+ const rootCss = cssVars.size ? `:root{${[...cssVars].map(([name, v]) => `${v}:url(${available[name]})`).join(';')}}` : '';
+ const imagesJson = JSON.stringify(Object.fromEntries([...used].map((n) => [n, available[n]])));
+ slidesHtml = slidesHtml.trim();
+
+ return `
+
+
+
+
+ ${escapeHtml(o.title)}
+
+
+
+
+
+ ${rootCss ? `` : ''}
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
diff --git a/tools/xml-lite.mjs b/tools/xml-lite.mjs
new file mode 100644
index 0000000..2ccc0b2
--- /dev/null
+++ b/tools/xml-lite.mjs
@@ -0,0 +1,106 @@
+/**
+ * A small XML parser for OOXML parts. Runs unchanged in Node and the browser
+ * (no DOMParser), and returns a plain tree that is cheap to walk:
+ *
+ * { name: 'a:p', attrs: { ... }, children: [...elements], text: 'own text' }
+ *
+ * Prefixes are kept as written. PowerPoint always uses the same ones (p:, a:,
+ * r:, mc:), and every part is read through them.
+ */
+
+const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
+
+export const decodeEntities = (s) => (s.indexOf('&') < 0 ? s : s.replace(
+ /&(#x[0-9a-f]+|#\d+|lt|gt|amp|quot|apos);/gi,
+ (m, e) => {
+ if (e[0] !== '#') return ENTITIES[e.toLowerCase()];
+ const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1));
+ return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : m;
+ },
+));
+
+const ATTR = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+
+function parseAttrs(source) {
+ const attrs = {};
+ ATTR.lastIndex = 0;
+ let m;
+ while ((m = ATTR.exec(source))) attrs[m[1]] = decodeEntities(m[2] ?? m[3]);
+ return attrs;
+}
+
+export function parseXml(xml) {
+ const root = { name: '#root', attrs: {}, children: [], text: '' };
+ const stack = [root];
+ let i = 0;
+ const len = xml.length;
+
+ while (i < len) {
+ const lt = xml.indexOf('<', i);
+ const top = stack[stack.length - 1];
+ if (lt < 0) { top.text += decodeEntities(xml.slice(i)); break; }
+ if (lt > i) top.text += decodeEntities(xml.slice(i, lt));
+
+ if (xml.startsWith('', lt + 4);
+ i = end < 0 ? len : end + 3;
+ } else if (xml.startsWith('', lt + 9);
+ top.text += xml.slice(lt + 9, end < 0 ? len : end);
+ i = end < 0 ? len : end + 3;
+ } else if (xml[lt + 1] === '?' || xml[lt + 1] === '!') {
+ const end = xml.indexOf('>', lt + 2);
+ i = end < 0 ? len : end + 1;
+ } else if (xml[lt + 1] === '/') {
+ const end = xml.indexOf('>', lt + 2);
+ if (stack.length > 1) stack.pop();
+ i = end < 0 ? len : end + 1;
+ } else {
+ // Find the end of the tag, skipping any '>' inside a quoted attribute.
+ let j = lt + 1;
+ let quote = '';
+ while (j < len) {
+ const c = xml[j];
+ if (quote) { if (c === quote) quote = ''; } else if (c === '"' || c === "'") quote = c;
+ else if (c === '>') break;
+ j++;
+ }
+ const selfClosing = xml[j - 1] === '/';
+ const body = xml.slice(lt + 1, selfClosing ? j - 1 : j);
+ const space = body.search(/\s/);
+ const name = space < 0 ? body : body.slice(0, space);
+ const node = { name, attrs: space < 0 ? {} : parseAttrs(body.slice(space)), children: [], text: '' };
+ top.children.push(node);
+ if (!selfClosing) stack.push(node);
+ i = j + 1;
+ }
+ }
+ return root.children[0] ?? root;
+}
+
+/** First direct child with this name. */
+export const child = (node, name) => node?.children.find((c) => c.name === name) ?? null;
+
+/** Every direct child with this name. */
+export const kids = (node, name) => (node ? node.children.filter((c) => c.name === name) : []);
+
+/** Follow a path of child names: path(n, 'p:nvSpPr', 'p:cNvPr'). */
+export function path(node, ...names) {
+ let cur = node;
+ for (const n of names) {
+ cur = child(cur, n);
+ if (!cur) return null;
+ }
+ return cur;
+}
+
+/** Depth-first search for every descendant with this name. */
+export function descendants(node, name, out = []) {
+ for (const c of node?.children ?? []) {
+ if (c.name === name) out.push(c);
+ descendants(c, name, out);
+ }
+ return out;
+}
+
+export const attrOf = (node, name, fallback = null) => node?.attrs[name] ?? fallback;
diff --git a/tools/zip-tools.mjs b/tools/zip-tools.mjs
new file mode 100644
index 0000000..512e795
--- /dev/null
+++ b/tools/zip-tools.mjs
@@ -0,0 +1,90 @@
+/**
+ * ZIP helpers for the browser converter: read the .pptx (inflate every part) and
+ * write the deck folder back out as a ZIP. No dependencies.
+ */
+import { listZipEntries } from './pptx-core.mjs';
+
+export async function inflateRaw(bytes) {
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
+ return new Uint8Array(await new Response(stream).arrayBuffer());
+}
+
+/** Inflate every part up front, so the converter itself stays synchronous. */
+export async function readPptx(bytes) {
+ const files = new Map();
+ for (const entry of listZipEntries(bytes)) {
+ const raw = bytes.subarray(entry.dataStart, entry.dataStart + entry.compSize);
+ if (entry.method === 0) files.set(entry.name, raw);
+ else if (entry.method === 8) files.set(entry.name, await inflateRaw(raw));
+ else throw new Error(`Unsupported compression in ${entry.name}`);
+ }
+ return files;
+}
+
+const CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ table[i] = c >>> 0;
+ }
+ return table;
+})();
+
+export const crc32 = (bytes) => {
+ let c = 0xffffffff;
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+};
+
+/** A ZIP with everything stored uncompressed; the payload is mostly PNG and SVG. */
+export function makeZip(entries) {
+ const encoder = new TextEncoder();
+ const chunks = [];
+ const central = [];
+ let offset = 0;
+
+ for (const { name, bytes } of entries) {
+ const nameBytes = encoder.encode(name);
+ const crc = crc32(bytes);
+
+ const local = new Uint8Array(30 + nameBytes.length);
+ const lv = new DataView(local.buffer);
+ lv.setUint32(0, 0x04034b50, true);
+ lv.setUint16(4, 20, true);
+ lv.setUint16(6, 0x0800, true); // UTF-8 names
+ lv.setUint32(14, crc, true);
+ lv.setUint32(18, bytes.length, true);
+ lv.setUint32(22, bytes.length, true);
+ lv.setUint16(26, nameBytes.length, true);
+ local.set(nameBytes, 30);
+ chunks.push(local, bytes);
+
+ const dir = new Uint8Array(46 + nameBytes.length);
+ const dv = new DataView(dir.buffer);
+ dv.setUint32(0, 0x02014b50, true);
+ dv.setUint16(4, 20, true);
+ dv.setUint16(6, 20, true);
+ dv.setUint16(8, 0x0800, true);
+ dv.setUint32(16, crc, true);
+ dv.setUint32(20, bytes.length, true);
+ dv.setUint32(24, bytes.length, true);
+ dv.setUint16(28, nameBytes.length, true);
+ dv.setUint32(42, offset, true);
+ dir.set(nameBytes, 46);
+ central.push(dir);
+
+ offset += local.length + bytes.length;
+ }
+
+ const centralSize = central.reduce((n, c) => n + c.length, 0);
+ const end = new Uint8Array(22);
+ const ev = new DataView(end.buffer);
+ ev.setUint32(0, 0x06054b50, true);
+ ev.setUint16(8, entries.length, true);
+ ev.setUint16(10, entries.length, true);
+ ev.setUint32(12, centralSize, true);
+ ev.setUint32(16, offset, true);
+
+ return new Blob([...chunks, ...central, end], { type: 'application/zip' });
+}
From 64960415ef8f4e9a0230317245cadf5db8a7c81f Mon Sep 17 00:00:00 2001
From: Ivor Berry <11559977+ivorb@users.noreply.github.com>
Date: Wed, 4 Nov 2026 14:34:53 -0500
Subject: [PATCH 4/9] Landing page: convert button at the top
---
index.html | 37 ++++++++++++++++++++++++++++++++-----
1 file changed, 32 insertions(+), 5 deletions(-)
diff --git a/index.html b/index.html
index 0178329..7008654 100644
--- a/index.html
+++ b/index.html
@@ -33,6 +33,31 @@
font-size: 1.05rem;
}
+ header.top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ flex-wrap: wrap;
+ }
+
+ .convert-btn {
+ display: inline-block;
+ padding: 10px 18px;
+ border-radius: 8px;
+ background: var(--brand-accent);
+ color: #10131a;
+ font-weight: 600;
+ font-size: 0.95rem;
+ text-decoration: none;
+ white-space: nowrap;
+ margin-top: 6px;
+ }
+
+ .convert-btn:hover {
+ filter: brightness(1.1);
+ }
+
.grid {
display: grid;
gap: 16px;
@@ -150,14 +175,16 @@
-
- Decks as Code
- Presentations that live in git, review as text, and publish themselves.
+
+
+ Decks as Code
+ Presentations that live in git, review as text, and publish themselves.
+
+ Convert a new deck
- Have a deck in PowerPoint already?
- Convert a .pptx to HTML → ·
+ Prefer Markdown slides?
Import a .pptx as Markdown →
From 3de478863c4c8b1235941e6d3219af555fde6709 Mon Sep 17 00:00:00 2001
From: Ivor Berry <11559977+ivorb@users.noreply.github.com>
Date: Wed, 4 Nov 2026 14:42:29 -0500
Subject: [PATCH 5/9] Add 5-slide converted sample deck; --slides option; hide
EMF logos on layouts
---
decks.json | 12 +-
decks/sample-ai-apps/deck.json | 14 +
decks/sample-ai-apps/images/image12.png | Bin 0 -> 1825068 bytes
decks/sample-ai-apps/images/image25.svg | 1 +
decks/sample-ai-apps/images/image26.svg | 1 +
decks/sample-ai-apps/images/image27.svg | 1 +
decks/sample-ai-apps/images/image28.svg | 1 +
decks/sample-ai-apps/images/image29.svg | 1 +
decks/sample-ai-apps/images/image3.png | Bin 0 -> 299601 bytes
decks/sample-ai-apps/images/image30.svg | 1 +
decks/sample-ai-apps/images/image31.svg | 1 +
decks/sample-ai-apps/images/image32.svg | 1 +
decks/sample-ai-apps/images/image33.png | Bin 0 -> 1321 bytes
decks/sample-ai-apps/images/image33.svg | 6 +
decks/sample-ai-apps/images/image34.png | Bin 0 -> 2457 bytes
decks/sample-ai-apps/images/image35.png | Bin 0 -> 390 bytes
decks/sample-ai-apps/images/image36.png | Bin 0 -> 6815 bytes
decks/sample-ai-apps/images/image37.png | Bin 0 -> 5684 bytes
decks/sample-ai-apps/images/image38.svg | 6 +
decks/sample-ai-apps/images/image39.svg | 6 +
decks/sample-ai-apps/images/image40.svg | 6 +
decks/sample-ai-apps/images/image41.png | Bin 0 -> 5270 bytes
decks/sample-ai-apps/images/image42.svg | 9 +
decks/sample-ai-apps/images/image43.svg | 6 +
decks/sample-ai-apps/images/image44.svg | 6 +
decks/sample-ai-apps/images/image45.svg | 6 +
decks/sample-ai-apps/images/image46.svg | 1 +
decks/sample-ai-apps/images/image47.png | Bin 0 -> 5436 bytes
decks/sample-ai-apps/images/image48.svg | 1 +
decks/sample-ai-apps/images/image49.svg | 6 +
decks/sample-ai-apps/images/image50.svg | 6 +
decks/sample-ai-apps/images/image51.png | Bin 0 -> 32722 bytes
decks/sample-ai-apps/slides.html | 159 +++++
decks/sample-ai-apps/strings.json | 841 ++++++++++++++++++++++++
tools/pptx-html.mjs | 5 +-
tools/pptx-to-html.mjs | 10 +-
36 files changed, 1111 insertions(+), 3 deletions(-)
create mode 100644 decks/sample-ai-apps/deck.json
create mode 100644 decks/sample-ai-apps/images/image12.png
create mode 100644 decks/sample-ai-apps/images/image25.svg
create mode 100644 decks/sample-ai-apps/images/image26.svg
create mode 100644 decks/sample-ai-apps/images/image27.svg
create mode 100644 decks/sample-ai-apps/images/image28.svg
create mode 100644 decks/sample-ai-apps/images/image29.svg
create mode 100644 decks/sample-ai-apps/images/image3.png
create mode 100644 decks/sample-ai-apps/images/image30.svg
create mode 100644 decks/sample-ai-apps/images/image31.svg
create mode 100644 decks/sample-ai-apps/images/image32.svg
create mode 100644 decks/sample-ai-apps/images/image33.png
create mode 100644 decks/sample-ai-apps/images/image33.svg
create mode 100644 decks/sample-ai-apps/images/image34.png
create mode 100644 decks/sample-ai-apps/images/image35.png
create mode 100644 decks/sample-ai-apps/images/image36.png
create mode 100644 decks/sample-ai-apps/images/image37.png
create mode 100644 decks/sample-ai-apps/images/image38.svg
create mode 100644 decks/sample-ai-apps/images/image39.svg
create mode 100644 decks/sample-ai-apps/images/image40.svg
create mode 100644 decks/sample-ai-apps/images/image41.png
create mode 100644 decks/sample-ai-apps/images/image42.svg
create mode 100644 decks/sample-ai-apps/images/image43.svg
create mode 100644 decks/sample-ai-apps/images/image44.svg
create mode 100644 decks/sample-ai-apps/images/image45.svg
create mode 100644 decks/sample-ai-apps/images/image46.svg
create mode 100644 decks/sample-ai-apps/images/image47.png
create mode 100644 decks/sample-ai-apps/images/image48.svg
create mode 100644 decks/sample-ai-apps/images/image49.svg
create mode 100644 decks/sample-ai-apps/images/image50.svg
create mode 100644 decks/sample-ai-apps/images/image51.png
create mode 100644 decks/sample-ai-apps/slides.html
create mode 100644 decks/sample-ai-apps/strings.json
diff --git a/decks.json b/decks.json
index 275fa9d..e95c5b8 100644
--- a/decks.json
+++ b/decks.json
@@ -1,7 +1,17 @@
{
"generatedBy": "tools/build-manifest.mjs",
- "count": 1,
+ "count": 2,
"decks": [
+ {
+ "slug": "sample-ai-apps",
+ "title": "Develop generative AI apps in Azure (5-slide sample)",
+ "description": "First five slides of a converted PowerPoint course deck, to show the PPTX-to-HTML converter.",
+ "author": "",
+ "date": "2026-11-04",
+ "tags": [],
+ "theme": "white",
+ "format": "html"
+ },
{
"slug": "decks-as-code",
"title": "Decks as Code",
diff --git a/decks/sample-ai-apps/deck.json b/decks/sample-ai-apps/deck.json
new file mode 100644
index 0000000..12450f9
--- /dev/null
+++ b/decks/sample-ai-apps/deck.json
@@ -0,0 +1,14 @@
+{
+ "title": "Develop generative AI apps in Azure (5-slide sample)",
+ "description": "First five slides of a converted PowerPoint course deck, to show the PPTX-to-HTML converter.",
+ "author": "",
+ "date": "2026-11-04",
+ "tags": [],
+ "theme": "white",
+ "format": "html",
+ "lang": "en-US",
+ "canvas": {
+ "width": 1280,
+ "height": 720
+ }
+}
diff --git a/decks/sample-ai-apps/images/image12.png b/decks/sample-ai-apps/images/image12.png
new file mode 100644
index 0000000000000000000000000000000000000000..e63b0678d7c87d55984283de6c604d77d5559a41
GIT binary patch
literal 1825068
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%0D>tl6X
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
wb640@+8Wnr`3vmm}!cQ^OT3AAa}0
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