From 4b85ae59fca3135880f30a414ab3cf9b292c8e35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:07:24 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 21 ++-- kiosk-app/docview.html | 60 ++---------- kiosk-app/docx-render.js | 110 +++++++++++++++++++++ kiosk-app/slideshow.html | 203 +++++++++++++++++++++++++++++++-------- kiosk-app/slideshow.js | 121 +++++++++++++++-------- 5 files changed, 371 insertions(+), 144 deletions(-) create mode 100644 kiosk-app/docx-render.js diff --git a/Readme.md b/Readme.md index 2fad438..9733385 100644 --- a/Readme.md +++ b/Readme.md @@ -197,14 +197,17 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c instead of a web address and it's shown from the kiosk's own disk: HTML pages, PDFs (built-in viewer), images, and Word documents (below). Works offline. The file must be readable by the `kiosk` user. -- **Image slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`) - and its images (jpg, png, gif, webp, bmp, svg, avif) play full-screen in - file-name order with a crossfade, 10 seconds each. Images added to or - removed from the folder are picked up within a minute, no restart - needed. Optional `slideshow.json` in the folder: - `{"interval": 10, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}` - (`fit`: `contain` shows the whole image, `cover` fills the screen and - crops; `recursive` includes subfolders). +- **Slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`) and + its images (jpg, png, gif, webp, bmp, svg, avif) and Word documents + (`.docx`) play full-screen in file-name order with a crossfade - images + 10 seconds each, documents 20. A one-page document is shown whole; a + longer one fills the width and scrolls slowly top to bottom during its + time. Files added, removed or changed are picked up within a minute, no + restart needed. Optional `slideshow.json` in the folder: + `{"interval": 10, "docInterval": 20, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}` + (`docInterval`: seconds per document; `fit`: `contain` shows the whole + image, `cover` fills the screen and crops; `recursive` includes + subfolders). - **Word documents** - A `.docx` path (e.g. `/home/kiosk/docs/menu.docx`) is rendered as pages (text, tables, images, lists), scaled to the screen width, via the bundled [docx-preview](https://github.com/VolodymyrBaydalka/docxjs) @@ -1474,7 +1477,7 @@ full migration pass. - **All-in-one install ISO** (`iso/build-iso.sh`, or Actions → Build kiosk ISO): Ubuntu Server 26.04 plus the kiosk in one USB stick — the Ubuntu install, then `install.sh` automatically on first boot. Interactive, unattended, or fully automatic. See "All-in-One Install ISO" above. - **Offline installs** (`--offline`): the ISO carries every package (including CUPS printing and all printer drivers), Node.js 22, and prebuilt Electron/Web UI modules, so a kiosk installs with no internet. With internet it still installs current versions online. Addons → CUPS Printing (menu or Web UI) installs from the bundle by itself when offline. - **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI. -- **Word documents as sites:** a `.docx` path renders in the kiosk via docx-preview (~1 MB of JavaScript, bundled offline), updating when the file changes. +- **Word documents as sites:** a `.docx` path renders in the kiosk via docx-preview (~1 MB of JavaScript, bundled offline), updating when the file changes. Slideshow folders can mix `.docx` documents in with images. - **Electron upgrade:** v42.x → v44.x (42 leaves Electron's support window when 45 ships). Existing kiosks move to it via Advanced → Upgrade. - **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`). diff --git a/kiosk-app/docview.html b/kiosk-app/docview.html index a28f805..9a8f88e 100644 --- a/kiosk-app/docview.html +++ b/kiosk-app/docview.html @@ -7,10 +7,6 @@ document's bytes arrive from main.js via window.kioskDocView.show(). --> +
Loading document…
@@ -26,6 +23,7 @@ (() => { const docEl = document.getElementById('doc'); const msg = document.getElementById('msg'); + let cls = null; function showMessage(text) { msg.textContent = text; @@ -33,65 +31,19 @@ docEl.innerHTML = ''; } - // Scale pages to the screen width (never above 100% + a little), so a - // portrait page is readable on a landscape kiosk without sideways - // scrolling. CSS zoom keeps scrolling and touch coordinates consistent. - function fitWidth() { - const page = docEl.querySelector('section.docx'); - if (!page) return; - docEl.style.zoom = 1; - const pageWidth = page.getBoundingClientRect().width + 32; - docEl.style.zoom = Math.min(window.innerWidth / pageWidth, 1.6); - } - window.addEventListener('resize', fitWidth); - - // 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 = { - '\uf0b7': '\u2022', // Symbol bullet -> • - '\uf0a7': '\u25aa', // Wingdings square -> ▪ - '\uf0d8': '\u27a2', // Wingdings arrowhead -> ➢ - '\uf076': '\u2756', // Wingdings diamond -> ❖ - '\uf0fc': '\u2713', // Wingdings check -> ✓ - '\uf06e': '\u25a0', // Wingdings black square -> ■ - '\uf071': '\u274f', // Wingdings box -> ❏ - }; - function fixSymbolBullets() { - document.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 "), in case it's - // emitted that way. - const hex = from.charCodeAt(0).toString(16); - css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to); - } - if (css !== before) st.textContent = css; - }); - } + window.addEventListener('resize', () => cls && KioskDocx.fit(docEl, cls, window.innerWidth)); window.kioskDocView = { async show(d) { if (d.error) { showMessage(d.error); return; } - if (!window.docx || !window.JSZip) { + if (!window.KioskDocx || !KioskDocx.available()) { showMessage('Word document viewer is missing (docx-preview) - run Advanced -> Upgrade.'); return; } try { - const bytes = Uint8Array.from(atob(d.data), (c) => c.charCodeAt(0)); - docEl.innerHTML = ''; - await window.docx.renderAsync(bytes, docEl, null, { - inWrapper: true, - ignoreLastRenderedPageBreak: true, - experimental: true, - useBase64URL: true, - }); + cls = await KioskDocx.render(KioskDocx.base64ToBytes(d.data), docEl); msg.style.display = 'none'; - fixSymbolBullets(); - fitWidth(); + KioskDocx.fit(docEl, cls, window.innerWidth); // Only called on first load or when the file changed on disk - // start a changed document from the top. window.scrollTo(0, 0); diff --git a/kiosk-app/docx-render.js b/kiosk-app/docx-render.js new file mode 100644 index 0000000..714418a --- /dev/null +++ b/kiosk-app/docx-render.js @@ -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
": 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; + }, + }; +})(); diff --git a/kiosk-app/slideshow.html b/kiosk-app/slideshow.html index 6e76f50..8b03460 100644 --- a/kiosk-app/slideshow.html +++ b/kiosk-app/slideshow.html @@ -3,34 +3,49 @@ Slideshow - + + + + - - +
+
Loading slideshow…
diff --git a/kiosk-app/slideshow.js b/kiosk-app/slideshow.js index c515b6a..30acf6f 100644 --- a/kiosk-app/slideshow.js +++ b/kiosk-app/slideshow.js @@ -2,20 +2,29 @@ // // A site whose URL is a local folder (file:///home/kiosk/photos, entered // in the Sites menu or Web UI as /home/kiosk/photos/) is shown as a -// full-screen slideshow of the images in it, instead of Chromium's -// directory listing. main.js swaps the folder URL for slideshow.html via -// resolveSiteUrl(), then hands the page its image list with attach() - -// the page itself has no filesystem access (contextIsolation, no Node). -// The folder is re-read every minute, so images added or removed show up -// without restarting the kiosk. +// full-screen slideshow of the images and Word documents (.docx) in it, +// instead of Chromium's directory listing. main.js swaps the folder URL +// for slideshow.html via resolveSiteUrl(), then attach() hands the page +// its item list - the page itself has no filesystem access +// (contextIsolation, no Node). The folder is re-read every minute, so +// files added, removed or changed show up without restarting the kiosk. +// +// Images load directly by file:// URL. A document's bytes are fetched on +// demand: the page sets its title to TITLE_REQUEST + the document's URL, +// and attach() answers via kioskSlideshow.docData() - but only for a +// .docx in that slideshow's own current item list, so a page can't use +// this to read anything else on disk. // // Optional slideshow.json in the folder (all keys optional): // { "interval": 10, seconds per image (default 10, minimum 2) +// "docInterval": 20, seconds per document (default 2x interval, +// minimum 5); a document longer than the +// screen scrolls slowly top to bottom in it // "shuffle": false, random order instead of by file name -// "fit": "contain", "contain" (whole image, letterboxed) or -// "cover" (fill the screen, cropped) +// "fit": "contain", images: "contain" (whole image, letterboxed) +// or "cover" (fill the screen, cropped) // "transition": 1, crossfade seconds (0 = instant cut) -// "recursive": false } include images in subfolders +// "recursive": false } include files in subfolders const fs = require('fs'); const path = require('path'); @@ -23,13 +32,16 @@ const { fileURLToPath, pathToFileURL } = require('url'); const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html'); const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']); +const DOC_EXT = new Set(['.docx']); const REFRESH_MS = 60000; -const DEFAULTS = { interval: 10, shuffle: false, fit: 'contain', transition: 1, recursive: false }; +const MAX_DOC_BYTES = 50 * 1024 * 1024; +const TITLE_REQUEST = 'kiosk-slideshow-need:'; +const DEFAULTS = { interval: 10, docInterval: null, shuffle: false, fit: 'contain', transition: 1, recursive: false }; // The folder path a site URL points at, or null if it isn't a slideshow. // A file:// URL counts when it's an existing directory, or ends in "/" // (so a folder that isn't there yet - e.g. a USB stick not plugged in - -// still shows the slideshow's "no images" message, not an error page). +// still shows the slideshow's "nothing to show" message, not an error). function slideshowDir(url) { if (typeof url !== 'string' || !url.startsWith('file://')) return null; let p; @@ -57,20 +69,22 @@ function resolveSiteUrl(url) { function readOptions(dir) { const opts = { ...DEFAULTS }; + let user = {}; try { - const user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8')); - if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval)); - if (typeof user.shuffle === 'boolean') opts.shuffle = user.shuffle; - if (user.fit === 'contain' || user.fit === 'cover') opts.fit = user.fit; - if (Number(user.transition) >= 0) opts.transition = Math.min(Number(user.transition), opts.interval / 2); - if (typeof user.recursive === 'boolean') opts.recursive = user.recursive; + user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8')); } catch (e) { // No (or unreadable) slideshow.json - defaults. } + if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval)); + opts.docInterval = Number(user.docInterval) > 0 ? Math.max(5, Number(user.docInterval)) : opts.interval * 2; + if (typeof user.shuffle === 'boolean') opts.shuffle = user.shuffle; + if (user.fit === 'contain' || user.fit === 'cover') opts.fit = user.fit; + if (Number(user.transition) >= 0) opts.transition = Math.min(Number(user.transition), opts.interval / 2); + if (typeof user.recursive === 'boolean') opts.recursive = user.recursive; return opts; } -function listImages(dir, recursive, depth = 0) { +function listItems(dir, recursive, depth = 0) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -79,12 +93,18 @@ function listImages(dir, recursive, depth = 0) { } const out = []; for (const e of entries) { - if (e.name.startsWith('.')) continue; + // Skip hidden files and Word's "~$name.docx" lock files. + if (e.name.startsWith('.') || e.name.startsWith('~$')) continue; const full = path.join(dir, e.name); + const ext = path.extname(e.name).toLowerCase(); if (e.isDirectory()) { - if (recursive && depth < 8) out.push(...listImages(full, recursive, depth + 1)); - } else if (IMAGE_EXT.has(path.extname(e.name).toLowerCase())) { - out.push(full); + if (recursive && depth < 8) out.push(...listItems(full, recursive, depth + 1)); + } else if (IMAGE_EXT.has(ext)) { + out.push({ path: full, type: 'image' }); + } else if (DOC_EXT.has(ext)) { + let mtime = 0; + try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ } + out.push({ path: full, type: 'docx', mtime }); } } return out; @@ -92,36 +112,59 @@ function listImages(dir, recursive, depth = 0) { function snapshot(dir) { const options = readOptions(dir); - const files = listImages(dir, options.recursive) - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); - return { - dir, - exists: fs.existsSync(dir), - options, - images: files.map((f) => pathToFileURL(f).href), - }; + const items = listItems(dir, options.recursive) + .sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' })) + .map((it) => ({ url: pathToFileURL(it.path).href, type: it.type, ...(it.mtime ? { mtime: it.mtime } : {}) })); + return { dir, exists: fs.existsSync(dir), options, items }; } -// Feed a view's slideshow page its image list now and every REFRESH_MS -// while it stays on a slideshow. Call once per view (main.js does, right -// after creating it); safe on views that never show a slideshow. +function readDoc(fileUrl) { + try { + const file = fileURLToPath(fileUrl); + const st = fs.statSync(file); + if (st.size > MAX_DOC_BYTES) return { error: `too large (${Math.round(st.size / 1048576)} MB)` }; + return { mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') }; + } catch (e) { + return { error: e.code === 'ENOENT' ? 'file not found' : e.message }; + } +} + +// Feed a view's slideshow page its item list now and every REFRESH_MS +// while it stays on a slideshow, and answer its document requests. Call +// once per view (main.js does, right after creating it); safe on views +// that never show a slideshow. function attach(webContents) { + const pagePrefix = pathToFileURL(SLIDESHOW_PAGE).href; let lastSent = ''; + let current = null; // latest snapshot sent to the page + + const onSlideshow = () => !webContents.isDestroyed() && webContents.getURL().startsWith(pagePrefix); + const send = (force) => { - if (webContents.isDestroyed()) return; - const current = webContents.getURL(); - if (!current.startsWith(pathToFileURL(SLIDESHOW_PAGE).href)) return; - const dir = new URL(current).searchParams.get('dir'); + if (!onSlideshow()) return; + const dir = new URL(webContents.getURL()).searchParams.get('dir'); if (!dir) return; - const payload = JSON.stringify(snapshot(dir)); + const snap = snapshot(dir); + const payload = JSON.stringify(snap); if (!force && payload === lastSent) return; lastSent = payload; + current = snap; webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`) .catch(() => {}); }; + webContents.on('did-finish-load', () => send(true)); + webContents.on('page-title-updated', (event, title) => { + if (!title.startsWith(TITLE_REQUEST) || !onSlideshow() || !current) return; + const url = title.slice(TITLE_REQUEST.length); + if (!current.items.some((it) => it.type === 'docx' && it.url === url)) return; + const doc = readDoc(url); + webContents.executeJavaScript( + `window.kioskSlideshow&&window.kioskSlideshow.docData(${JSON.stringify(url)},${JSON.stringify(doc)})`, + ).catch(() => {}); + }); const timer = setInterval(() => send(false), REFRESH_MS); webContents.once('destroyed', () => clearInterval(timer)); } -module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot }; +module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot, TITLE_REQUEST };