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