Word (.docx) documents as sites, via docx-preview
A site whose path is a local .docx renders as pages in the kiosk instead of Chromium offering a download. kiosk-app/docview.js maps the file URL to docview.html (alongside slideshow.js, through main.js's new resolveLocalSite) and pushes the file's bytes into the page from the main process; the page renders them with docx-preview + jszip, both pure-JS npm dependencies of kiosk-app (so online installs, Advanced -> Upgrade and the offline bundle all pick them up - no LibreOffice). - Scaled to the screen width (capped at 160%), scrolls normally. - Re-checked every minute; a changed file re-renders from the top. - Word's Symbol/Wingdings private-use bullet glyphs (U+F0B7 etc.), which render blank without Microsoft fonts, are mapped to Unicode (•, ▪, ...). - Missing/unreadable/oversized (>50 MB) files and a missing library show a message instead of a blank page. .doc/.odt aren't supported. Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox: heading, coloured bold run, image, bordered table, bullets and a page break rendered; wheel scrolling works; replacing the file while running re-rendered the new document within the refresh. Offline bundle rebuilt and contains docx-preview and jszip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Document</title>
|
||||
<!-- Shown for a site that's a local .docx file - see docview.js. The
|
||||
document's bytes arrive from main.js via window.kioskDocView.show(). -->
|
||||
<style>
|
||||
html, body { margin: 0; background: #666; }
|
||||
#doc { transform-origin: top left; }
|
||||
/* docx-preview's page wrapper: grey backdrop with white pages. */
|
||||
#doc .docx-wrapper { background: #666; padding: 16px 0; }
|
||||
#doc .docx-wrapper > section.docx { margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,.4); }
|
||||
#msg {
|
||||
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #eee; font: 22px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="msg">Loading document…</div>
|
||||
<div id="doc"></div>
|
||||
<script>
|
||||
(() => {
|
||||
const docEl = document.getElementById('doc');
|
||||
const msg = document.getElementById('msg');
|
||||
|
||||
function showMessage(text) {
|
||||
msg.textContent = text;
|
||||
msg.style.display = 'flex';
|
||||
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.kioskDocView = {
|
||||
async show(d) {
|
||||
if (d.error) { showMessage(d.error); return; }
|
||||
if (!window.docx || !window.JSZip) {
|
||||
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,
|
||||
});
|
||||
msg.style.display = 'none';
|
||||
fixSymbolBullets();
|
||||
fitWidth();
|
||||
// Only called on first load or when the file changed on disk -
|
||||
// start a changed document from the top.
|
||||
window.scrollTo(0, 0);
|
||||
} catch (e) {
|
||||
showMessage(`Couldn't show ${d.file}: ${e.message || e}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 };
|
||||
+7
-3
@@ -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',()=>{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user