Slideshow folders can mix Word documents in with images

A slideshow folder now plays .docx files alongside images: a one-page
document is shown whole (letterboxed like a photo); a longer one fills
the width and scrolls smoothly top to bottom during its docInterval
(new slideshow.json key, default 2x interval). Word's ~$ lock files are
skipped; changed documents (by mtime) are re-read on the minute refresh.

- kiosk-app/docx-render.js: the docx-preview rendering now shared by
  docview.html and slideshow.html. Each render gets its own class prefix
  and keeps its styles in its own container, so two documents crossfading
  can't restyle each other. "One page" is judged by content fitting the
  paper height, not section count - docx-preview only starts a new
  section at explicit page breaks, so a long unbroken document is one
  tall section.
- Document bytes are fetched on demand: the page sets its title to a
  request, slideshow.js answers via executeJavaScript - only for a .docx
  in that slideshow's own current listing (no file-read API exposed to
  pages; the preload is shared by every site).

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox on a
folder of 2 images + a 1-page and a long .docx (+ a ~$ lock file): order,
crossfades, whole-page fit, scroll to the end, lock file skipped; the
single-.docx site still renders. The request handler answered a listed
.docx and refused /etc/passwd, a listed image, and a .docx outside the
folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
Claude
2026-10-03 15:07:24 +00:00
parent c54ae352a1
commit 4b85ae59fc
5 changed files with 371 additions and 144 deletions
+110
View File
@@ -0,0 +1,110 @@
// Browser-side .docx rendering shared by docview.html (one document as a
// site) and slideshow.html (documents mixed into a folder slideshow).
// Needs node_modules/jszip and node_modules/docx-preview loaded first.
//
// Each render gets its own class prefix and keeps its generated styles
// inside its own container, so two documents on screen at once (a
// slideshow crossfade) can't restyle each other, and removing the
// container removes its styles.
(() => {
let counter = 0;
// Word draws most bullets with private-use characters from the Symbol
// and Wingdings fonts (U+F0B7 etc.), which a kiosk doesn't have - they
// render as blanks. docx-preview passes them through into its generated
// list CSS; swap the common ones for real Unicode equivalents.
const SYMBOL_BULLETS = {
'': '•', // Symbol bullet -> •
'': '▪', // Wingdings square -> ▪
'': '➢', // Wingdings arrowhead -> ➢
'': '❖', // Wingdings diamond -> ❖
'': '✓', // Wingdings check -> ✓
'': '■', // Wingdings black square -> ■
'': '❏', // Wingdings box -> ❏
};
function fixSymbolBullets(container) {
container.querySelectorAll('style').forEach((st) => {
let css = st.textContent;
const before = css;
for (const [from, to] of Object.entries(SYMBOL_BULLETS)) {
css = css.split(from).join(to);
// Also the CSS-escaped form ("\f0b7" / "\00f0b7 ").
const hex = from.charCodeAt(0).toString(16);
css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to);
}
if (css !== before) st.textContent = css;
});
}
window.KioskDocx = {
available() {
return !!(window.docx && window.JSZip);
},
base64ToBytes(b64) {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
},
// Render into `container` (emptied first). Returns the class prefix
// used, for fit().
async render(bytes, container) {
const cls = `kdocx${++counter}`;
container.innerHTML = '';
await window.docx.renderAsync(bytes, container, container, {
className: cls,
inWrapper: true,
ignoreLastRenderedPageBreak: true,
experimental: true,
useBase64URL: true,
});
const wrapper = container.querySelector(`.${cls}-wrapper`);
if (wrapper) {
wrapper.style.background = 'transparent';
wrapper.style.padding = '16px 0';
}
container.querySelectorAll(`section.${cls}`).forEach((s) => {
s.style.marginBottom = '16px';
s.style.boxShadow = '0 2px 8px rgba(0,0,0,.4)';
});
fixSymbolBullets(container);
return cls;
},
// Scale `zoomEl` so the first page fills `width` (capped at maxZoom),
// so a portrait page is readable on a landscape screen without
// sideways scrolling. CSS zoom keeps scrolling and touch coordinates
// consistent.
fit(zoomEl, cls, width, maxZoom = 1.6) {
const page = zoomEl.querySelector(`section.${cls}`);
if (!page) return;
zoomEl.style.zoom = 1;
const pageWidth = page.getBoundingClientRect().width + 32;
zoomEl.style.zoom = Math.min(width / pageWidth, maxZoom);
},
// Scale `zoomEl` so the whole first page fits in width x height
// (letterboxed, like an image) - for one-page documents in a slideshow,
// where scrolling through the page's empty bottom would be pointless.
fitPage(zoomEl, cls, width, height) {
const page = zoomEl.querySelector(`section.${cls}`);
if (!page) return;
zoomEl.style.zoom = 1;
const r = page.getBoundingClientRect();
zoomEl.style.zoom = Math.min(width / (r.width + 32), height / (r.height + 32));
},
// True when the whole document fits on one physical page. Not just
// "one <section>": docx-preview doesn't paginate by itself - it only
// starts a new section at an explicit page break - so a long document
// without breaks is one very tall section. Each section's min-height
// is the paper height, so content that fits leaves it at that height.
isSinglePage(zoomEl, cls) {
const pages = zoomEl.querySelectorAll(`section.${cls}`);
if (pages.length !== 1) return false;
const paper = parseFloat(getComputedStyle(pages[0]).minHeight);
if (!paper) return false;
return pages[0].getBoundingClientRect().height <= paper * 1.02;
},
};
})();