diff --git a/Readme.md b/Readme.md
index aad83cb..2fad438 100644
--- a/Readme.md
+++ b/Readme.md
@@ -195,7 +195,7 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
- **Navigation menu** - Quick access to all sites via key icon (top-left hot corner)
- **Local files** - Enter an absolute path (e.g. `/home/kiosk/docs/menu.pdf`)
instead of a web address and it's shown from the kiosk's own disk:
- HTML pages, PDFs (built-in viewer), and images. Works offline. The file
+ 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
@@ -205,6 +205,13 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
`{"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).
+- **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)
+ library - no LibreOffice needed, works offline. Replacing the file on
+ disk updates the kiosk within a minute. Old `.doc` and LibreOffice
+ `.odt` aren't supported; save those as `.docx` or PDF. Layout is close
+ to Word's but not pixel-exact - for exact layout, export to PDF.
### Touch Controls
- **2-finger horizontal swipe** - Switch between sites
@@ -1467,6 +1474,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.
- **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
new file mode 100644
index 0000000..a28f805
--- /dev/null
+++ b/kiosk-app/docview.html
@@ -0,0 +1,106 @@
+
+
+
+
+Document
+
+
+
+
+
+
+Loading document…
+
+
+
+
diff --git a/kiosk-app/docview.js b/kiosk-app/docview.js
new file mode 100644
index 0000000..668d67c
--- /dev/null
+++ b/kiosk-app/docview.js
@@ -0,0 +1,82 @@
+// Word document (.docx) support for main.js.
+//
+// A site whose URL is a local .docx file (file:///home/kiosk/docs/menu.docx,
+// entered in the Sites menu or Web UI as /home/kiosk/docs/menu.docx) is
+// rendered with docx-preview (an npm dependency of this app - pure
+// JavaScript, works offline) instead of Chromium offering to download it.
+// main.js swaps the file URL for docview.html via resolveSiteUrl(), then
+// attach() hands the page the file's bytes - the page itself has no
+// filesystem access (contextIsolation, no Node). The file is re-checked
+// every minute and re-rendered if it changed, so replacing the document
+// on disk updates the kiosk without a restart.
+//
+// Only .docx (Word 2007+). Old binary .doc and LibreOffice .odt aren't
+// supported by docx-preview - save those as .docx or PDF.
+
+const fs = require('fs');
+const path = require('path');
+const { fileURLToPath, pathToFileURL } = require('url');
+
+const VIEWER_PAGE = path.join(__dirname, 'docview.html');
+const REFRESH_MS = 60000;
+const MAX_BYTES = 50 * 1024 * 1024;
+
+// The .docx path a site URL points at, or null.
+function docxPath(url) {
+ if (typeof url !== 'string' || !url.startsWith('file://')) return null;
+ let p;
+ try {
+ p = fileURLToPath(url);
+ } catch (e) {
+ return null;
+ }
+ return path.extname(p).toLowerCase() === '.docx' ? p : null;
+}
+
+// What a view should actually load for a configured site URL.
+function resolveSiteUrl(url) {
+ const file = docxPath(url);
+ if (!file) return url;
+ const u = pathToFileURL(VIEWER_PAGE);
+ u.searchParams.set('file', file);
+ return u.href;
+}
+
+function readDoc(file) {
+ try {
+ const st = fs.statSync(file);
+ if (st.size > MAX_BYTES) return { file, error: `Document is too large to show (${Math.round(st.size / 1048576)} MB).` };
+ return { file, mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
+ } catch (e) {
+ return { file, error: e.code === 'ENOENT' ? `File not found: ${file}` : `Can't read ${file}: ${e.message}` };
+ }
+}
+
+// Feed a view's viewer page the document now and again whenever the file
+// changes. Call once per view; safe on views that never show a document.
+function attach(webContents) {
+ let lastKey = '';
+ const send = (force) => {
+ if (webContents.isDestroyed()) return;
+ const current = webContents.getURL();
+ if (!current.startsWith(pathToFileURL(VIEWER_PAGE).href)) return;
+ const file = new URL(current).searchParams.get('file');
+ if (!file) return;
+ let key;
+ try {
+ const st = fs.statSync(file);
+ key = `${st.mtimeMs}:${st.size}`;
+ } catch (e) {
+ key = 'missing';
+ }
+ if (!force && key === lastKey) return;
+ lastKey = key;
+ webContents.executeJavaScript(`window.kioskDocView&&window.kioskDocView.show(${JSON.stringify(readDoc(file))})`)
+ .catch(() => {});
+ };
+ webContents.on('did-finish-load', () => send(true));
+ const timer = setInterval(() => send(false), REFRESH_MS);
+ webContents.once('destroyed', () => clearInterval(timer));
+}
+
+module.exports = { resolveSiteUrl, docxPath, attach };
diff --git a/kiosk-app/main.js b/kiosk-app/main.js
index d0acd51..84deff2 100644
--- a/kiosk-app/main.js
+++ b/kiosk-app/main.js
@@ -4,8 +4,11 @@ const fs=require('fs');
const path=require('path');
const os=require('os');
const crypto=require('crypto');
-// Sites that are a local folder show as an image slideshow (slideshow.js).
+// Local-file sites with their own viewer pages: a folder plays as an image
+// slideshow (slideshow.js), a .docx renders as a Word document (docview.js).
const slideshow=require('./slideshow');
+const docview=require('./docview');
+function resolveLocalSite(url){return docview.resolveSiteUrl(slideshow.resolveSiteUrl(url));}
// Suppress EPIPE errors (happen when no terminal attached)
process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;});
@@ -784,7 +787,7 @@ function attachView(i){
const tabIdx=viewIndexToTabIndex(i);
if(tabIdx>=0&&tabs[tabIdx]){
- const configuredUrl=slideshow.resolveSiteUrl(tabs[tabIdx].url);
+ const configuredUrl=resolveLocalSite(tabs[tabIdx].url);
const currentUrl=views[i].webContents.getURL();
if(currentUrl&&!currentUrl.startsWith(configuredUrl)){
@@ -1336,7 +1339,7 @@ async function createWindow(){
url=u.toString();
}catch(e){}
}
- url=slideshow.resolveSiteUrl(url);
+ url=resolveLocalSite(url);
const initialOrigin=new URL(t.url).origin;
@@ -1371,6 +1374,7 @@ async function createWindow(){
view.webContents.setAudioMuted(false);
slideshow.attach(view.webContents);
+ docview.attach(view.webContents);
view.webContents.loadURL(url);
view.webContents.on('did-finish-load',()=>{
diff --git a/kiosk-app/package.json b/kiosk-app/package.json
index 0c2ca6c..c472b2b 100644
--- a/kiosk-app/package.json
+++ b/kiosk-app/package.json
@@ -3,6 +3,8 @@
"version": "1.0.0",
"main": "main.js",
"dependencies": {
- "electron": "^44.0.0"
+ "electron": "^44.0.0",
+ "docx-preview": "^0.4.1",
+ "jszip": "^3.10.1"
}
}
diff --git a/menus/sites.sh b/menus/sites.sh
index 06f27e4..b47660b 100644
--- a/menus/sites.sh
+++ b/menus/sites.sh
@@ -137,8 +137,8 @@ action_add_page() {
echo " -1 = hidden (PIN-gated, F10 or 3-finger swipe)"
echo
echo "URL: a web address, or a path on this kiosk - a file (page, PDF,"
- echo "image) like /home/kiosk/docs/menu.pdf, or a folder of images ending"
- echo "in / like /home/kiosk/photos/ for a slideshow."
+ echo "image, Word .docx) like /home/kiosk/docs/menu.pdf, or a folder of"
+ echo "images ending in / like /home/kiosk/photos/ for a slideshow."
local raw_url url dur name needs_auth user pass
read -r -p "URL: " raw_url