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 };