From 9ee34939cd1ba119580cad83d058ecd3a5d55a77 Mon Sep 17 00:00:00 2001
From: Ivor Berry <11559977+ivorb@users.noreply.github.com>
Date: Wed, 4 Nov 2026 15:17:09 -0500
Subject: [PATCH 1/2] Convert page: save decks for the session; preview in an
iframe so it no longer restyles the page
---
README.md | 3 +
convert.html | 233 ++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 186 insertions(+), 50 deletions(-)
diff --git a/README.md b/README.md
index 93d0fcd..ac38f74 100644
--- a/README.md
+++ b/README.md
@@ -165,6 +165,9 @@ Open `convert.html` on the served site (or `node tools/pptx-to-html.mjs talk.ppt
- **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.
+- **Save in this session** to come back to a conversion without downloading it. Saved
+ decks are listed on the page and kept in this browser tab only; closing the tab clears
+ them.
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
diff --git a/convert.html b/convert.html
index 39150a3..86b91fb 100644
--- a/convert.html
+++ b/convert.html
@@ -116,7 +116,22 @@
#preview { position: fixed; inset: 0; z-index: 50; background: var(--brand-bg); }
#preview.hidden { display: none; }
+ #preview iframe { width: 100%; height: 100%; border: 0; display: block; }
#closePreview { position: fixed; top: 14px; right: 16px; z-index: 60; background: rgba(0, 0, 0, 0.6); }
+
+ .saved { list-style: none; margin: 0; padding: 0; }
+ .saved li {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 10px 0;
+ border-top: 1px solid var(--brand-border);
+ }
+ .saved li:first-child { border-top: 0; padding-top: 0; }
+ .saved .what { flex: 1; min-width: 0; }
+ .saved .what b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .saved .what span { color: var(--brand-muted); font-size: 0.82rem; }
+ .saved button { padding: 6px 12px; font-size: 0.85rem; }
@@ -140,6 +155,14 @@
Convert a PowerPoint to HTML
+
+
Saved in this session
+
+
+ Kept in this browser tab only. They are gone when you close the tab, so
+ download anything you want to keep.
+
+
Converted
@@ -161,6 +184,7 @@
Converted
+
@@ -210,7 +234,7 @@
This page needs a web server
-
+
@@ -292,23 +316,21 @@
This page needs a web server
render();
}
- async function load(file) {
- if (!file.name.toLowerCase().endsWith('.pptx')) {
- setStatus('That is not a .pptx file.', 'error');
- return;
- }
- setStatus(`Reading ${file.name}…`);
+ /** Convert a .pptx already in memory. `fields` restores the form of a saved deck. */
+ async function loadBytes(name, bytes, fields = null) {
+ setStatus(`Reading ${name}…`);
try {
- const files = await readPptx(new Uint8Array(await file.arrayBuffer()));
- current = { name: file.name, files };
- const guess = slugify(file.name.replace(/\.pptx$/i, ''));
+ const files = await readPptx(bytes);
+ current = { name, files, bytes };
+ const guess = fields?.slug || slugify(name.replace(/\.pptx$/i, ''));
$('slug').value = guess;
- const probe = pptxToHtml(files, { slug: guess, description: `Converted from ${file.name}.` });
- $('title').value = probe.deck.title;
- $('author').value = probe.deck.author;
+ const probe = pptxToHtml(files, { slug: guess, description: `Converted from ${name}.` });
+ $('title').value = fields?.title || probe.deck.title;
+ $('author').value = fields?.author ?? probe.deck.author;
current.result = probe;
+ $('saveBtn').textContent = 'Save in this session';
render();
- setStatus(`Converted ${probe.stats.slides} slides from ${file.name}.`, 'ok');
+ setStatus(`Converted ${probe.stats.slides} slides from ${name}.`, 'ok');
} catch (error) {
current = null;
$('result').classList.add('hidden');
@@ -316,6 +338,119 @@
This page needs a web server
}
}
+ async function load(file) {
+ if (!file.name.toLowerCase().endsWith('.pptx')) {
+ setStatus('That is not a .pptx file.', 'error');
+ return;
+ }
+ await loadBytes(file.name, new Uint8Array(await file.arrayBuffer()));
+ }
+
+ /* ------------------------------------------- saved in this session -- */
+
+ /*
+ * Decks are kept as the original .pptx in IndexedDB (large files do not fit
+ * in sessionStorage) and converted again when opened, which takes well under
+ * a second. Each entry is stamped with a per-tab id from sessionStorage, and
+ * any entry from another tab session is deleted on load, so nothing outlives
+ * the tab it was saved in.
+ */
+ const DB_NAME = 'decks-session';
+ const STORE = 'decks';
+ const hasStorage = typeof indexedDB !== 'undefined';
+ let tabId = sessionStorage.getItem('convertTabId');
+ if (!tabId) {
+ tabId = crypto.randomUUID();
+ sessionStorage.setItem('convertTabId', tabId);
+ }
+
+ const openDb = () => new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'id' });
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+
+ async function dbRun(mode, action) {
+ const db = await openDb();
+ try {
+ return await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE, mode);
+ const request = action(transaction.objectStore(STORE));
+ transaction.oncomplete = () => resolve(request?.result);
+ transaction.onerror = () => reject(transaction.error);
+ transaction.onabort = () => reject(transaction.error);
+ });
+ } finally {
+ db.close();
+ }
+ }
+
+ async function listSaved() {
+ const all = (await dbRun('readonly', (s) => s.getAll())) ?? [];
+ const stale = all.filter((d) => d.tabId !== tabId);
+ for (const d of stale) await dbRun('readwrite', (s) => s.delete(d.id));
+ return all.filter((d) => d.tabId === tabId).sort((a, b) => b.savedAt - a.savedAt);
+ }
+
+ async function renderSaved() {
+ if (!hasStorage) return;
+ let decks = [];
+ try { decks = await listSaved(); } catch { /* storage blocked: no list */ }
+ $('saved').classList.toggle('hidden', decks.length === 0);
+ $('savedList').innerHTML = decks.map((d) => `
+
`).join('');
+ }
+
+ $('saveBtn').addEventListener('click', async () => {
+ if (!current) return;
+ try {
+ await dbRun('readwrite', (s) => s.put({
+ id: `${current.name}:${current.bytes.byteLength}`,
+ tabId,
+ name: current.name,
+ title: $('title').value,
+ slug: $('slug').value,
+ author: $('author').value,
+ slides: current.result.stats.slides,
+ bytes: current.bytes.buffer.slice(current.bytes.byteOffset, current.bytes.byteOffset + current.bytes.byteLength),
+ savedAt: Date.now(),
+ }));
+ $('saveBtn').textContent = 'Saved ✓';
+ setStatus('Saved in this session. It stays until you close this tab.', 'ok');
+ await renderSaved();
+ } catch (error) {
+ setStatus(`Could not save: ${error?.message || 'browser storage is full or blocked'}.`, 'error');
+ }
+ });
+
+ $('savedList').addEventListener('click', async (event) => {
+ const button = event.target.closest('button[data-act]');
+ if (!button) return;
+ const id = button.closest('li').dataset.id;
+ if (button.dataset.act === 'remove') {
+ await dbRun('readwrite', (s) => s.delete(id));
+ await renderSaved();
+ return;
+ }
+ const record = await dbRun('readonly', (s) => s.get(id));
+ if (record) {
+ await loadBytes(record.name, new Uint8Array(record.bytes), record);
+ $('saveBtn').textContent = 'Saved ✓';
+ $('result').scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ });
+
+ if (hasStorage) {
+ $('saveBtn').classList.remove('hidden');
+ renderSaved();
+ }
+
/* ------------------------------------------------------- downloads -- */
function deckFiles() {
@@ -421,38 +556,41 @@
This page needs a web server
/* --------------------------------------------------------- preview -- */
- const loadCss = (href) => new Promise((resolve) => {
- const link = document.createElement('link');
- link.rel = 'stylesheet';
- link.href = href;
- link.onload = link.onerror = () => resolve();
- document.head.appendChild(link);
- });
- const loadScript = (src) => new Promise((resolve, reject) => {
- const script = document.createElement('script');
- script.src = src;
- script.onload = resolve;
- script.onerror = () => reject(new Error(`Failed to load ${src}`));
- document.head.appendChild(script);
- });
-
- let revealReady = false;
+ /*
+ * The preview runs in its own iframe. Reveal's theme sets colors and sizes on
+ * body and html; loaded into this page it restyled the page itself and left
+ * the text black after the preview closed. A separate document keeps the two
+ * apart and gives every preview a fresh Reveal.
+ */
let previewUrls = [];
+ function previewDocument(html, deck) {
+ const canvas = deck.canvas;
+ const config = {
+ hash: false, slideNumber: 'c/t', transition: 'none', width: canvas.width, height: canvas.height,
+ margin: 0, minScale: 0.2, maxScale: 3, center: false,
+ };
+ const sheets = [
+ `${BASE}/reveal.js@${REVEAL_VERSION}/dist/reveal.css`,
+ `${BASE}/reveal.js@${REVEAL_VERSION}/dist/theme/${deck.theme || 'white'}.css`,
+ 'assets/theme.css',
+ 'assets/pptx-slide.css',
+ ];
+ return `
+${sheets.map((href) => ``).join('\n')}
+
+
${html}
+
-
+
@@ -328,7 +336,7 @@
This page needs a web server
$('title').value = fields?.title || probe.deck.title;
$('author').value = fields?.author ?? probe.deck.author;
current.result = probe;
- $('saveBtn').textContent = 'Save in this session';
+ $('saveBtn').textContent = 'Save in this browser';
render();
setStatus(`Converted ${probe.stats.slides} slides from ${name}.`, 'ok');
} catch (error) {
@@ -351,18 +359,14 @@
This page needs a web server
/*
* Decks are kept as the original .pptx in IndexedDB (large files do not fit
* in sessionStorage) and converted again when opened, which takes well under
- * a second. Each entry is stamped with a per-tab id from sessionStorage, and
- * any entry from another tab session is deleted on load, so nothing outlives
- * the tab it was saved in.
+ * a second. They stay until the user removes them or clears site data for this
+ * origin. They live in this browser only, and are never sent anywhere.
*/
const DB_NAME = 'decks-session';
const STORE = 'decks';
const hasStorage = typeof indexedDB !== 'undefined';
- let tabId = sessionStorage.getItem('convertTabId');
- if (!tabId) {
- tabId = crypto.randomUUID();
- sessionStorage.setItem('convertTabId', tabId);
- }
+ // Ask the browser not to evict saved decks under storage pressure; harmless if refused.
+ navigator.storage?.persist?.().catch(() => {});
const openDb = () => new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
@@ -388,9 +392,7 @@
This page needs a web server
async function listSaved() {
const all = (await dbRun('readonly', (s) => s.getAll())) ?? [];
- const stale = all.filter((d) => d.tabId !== tabId);
- for (const d of stale) await dbRun('readwrite', (s) => s.delete(d.id));
- return all.filter((d) => d.tabId === tabId).sort((a, b) => b.savedAt - a.savedAt);
+ return all.sort((a, b) => b.savedAt - a.savedAt);
}
async function renderSaved() {
@@ -398,21 +400,29 @@
`).join('');
}
+ $('removeAllBtn').addEventListener('click', async () => {
+ if (!confirm('Remove every saved deck from this browser?')) return;
+ await dbRun('readwrite', (s) => s.clear());
+ $('saveBtn').textContent = 'Save in this browser';
+ await renderSaved();
+ });
+
$('saveBtn').addEventListener('click', async () => {
if (!current) return;
try {
await dbRun('readwrite', (s) => s.put({
id: `${current.name}:${current.bytes.byteLength}`,
- tabId,
name: current.name,
title: $('title').value,
slug: $('slug').value,
@@ -422,7 +432,7 @@
This page needs a web server
savedAt: Date.now(),
}));
$('saveBtn').textContent = 'Saved ✓';
- setStatus('Saved in this session. It stays until you close this tab.', 'ok');
+ setStatus('Saved in this browser. It stays until you remove it or clear site data.', 'ok');
await renderSaved();
} catch (error) {
setStatus(`Could not save: ${error?.message || 'browser storage is full or blocked'}.`, 'error');