Slideshow folders can include PDFs
PDFs in a slideshow folder now play alongside images and Word documents: a one-page PDF is shown whole, a longer one fits the width and scrolls through its pages during docInterval (first 30 pages - rendering a huge PDF for a few seconds on screen isn't worth the time and memory). - kiosk-app/pdf-render.mjs renders pages to canvases with pdf.js (pdfjs-dist ^6.3.289, new kiosk-app dependency): no viewer toolbar, and the slideshow's existing auto-scroll works on it. pdf.js >= 4.2.67 is required (CVE-2024-4367: older versions could run script embedded in a crafted PDF); isEvalSupported:false too. It's a module because pdf.js only ships as one - confirmed to load (with its worker) from file:// in Electron 44. - Hands pdf.js a copy of the bytes: it transfers the buffer it's given, which would break the cached copy the next time round. - slideshow.js lists .pdf as a document type; the title-request handler serves any listed non-image item (still only from that folder). - provision_install_app now copies kiosk-app/*.mjs as well - without it pdf-render.mjs would never reach an installed kiosk. Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox, app files copied with provision_install_app's own glob: a photo, a one-page PDF, a three-page PDF and a .docx played in order, twice round; the one-page PDF shown whole, the three-page one scrolled through all pages. Offline bundle rebuilt with pdfjs-dist (437 MB total, +22 MB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
@@ -198,14 +198,14 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
|
||||
HTML pages, PDFs (built-in viewer), images, and Word documents (below). Works offline. The file
|
||||
must be readable by the `kiosk` user.
|
||||
- **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
|
||||
its images (jpg, png, gif, webp, bmp, svg, avif), Word documents
|
||||
(`.docx`) and PDFs play full-screen in file-name order with a crossfade -
|
||||
images 10 seconds each, documents and PDFs 20. A one-page document or
|
||||
PDF is shown whole; a longer one fills the width and scrolls slowly top
|
||||
to bottom during its time (PDFs: first 30 pages). 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
|
||||
(`docInterval`: seconds per document/PDF; `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`)
|
||||
@@ -1477,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. Slideshow folders can mix `.docx` documents in with images.
|
||||
- **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 and PDFs (rendered with pdf.js) 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`).
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"dependencies": {
|
||||
"electron": "^44.0.0",
|
||||
"docx-preview": "^0.4.1",
|
||||
"jszip": "^3.10.1"
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^6.3.289"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Browser-side PDF rendering for slideshow.html, via pdf.js (pdfjs-dist,
|
||||
// an npm dependency of this app). A module because pdf.js only ships as
|
||||
// one; it sets window.KioskPdf for slideshow.html's classic script.
|
||||
//
|
||||
// Pages are drawn to canvases (no viewer toolbar, and the slideshow can
|
||||
// scroll them like a Word document). pdf.js >= 4.2.67 is required - older
|
||||
// versions could run JavaScript embedded in a crafted PDF (CVE-2024-4367);
|
||||
// isEvalSupported:false is set as well, belt and braces.
|
||||
import * as pdfjs from './node_modules/pdfjs-dist/build/pdf.min.mjs';
|
||||
|
||||
const base = new URL('./node_modules/pdfjs-dist/', import.meta.url).href;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = base + 'build/pdf.worker.min.mjs';
|
||||
|
||||
// Rendering every page of a huge PDF would take a long time and a lot of
|
||||
// memory for something shown for seconds - stop after this many.
|
||||
const MAX_PAGES = 30;
|
||||
|
||||
window.KioskPdf = {
|
||||
// Render `bytes` into `container` (emptied first), sized for a screen of
|
||||
// width x height: a one-page PDF fits entirely (like an image); a longer
|
||||
// one fits the width, pages stacked, for the slideshow to scroll.
|
||||
async render(bytes, container, width, height) {
|
||||
container.innerHTML = '';
|
||||
const task = pdfjs.getDocument({
|
||||
// A copy: pdf.js transfers (empties) the buffer it's given, and the
|
||||
// slideshow caches these bytes to show the PDF again next round.
|
||||
data: bytes.slice(),
|
||||
isEvalSupported: false,
|
||||
cMapUrl: base + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: base + 'standard_fonts/',
|
||||
});
|
||||
try {
|
||||
const doc = await task.promise;
|
||||
const count = Math.min(doc.numPages, MAX_PAGES);
|
||||
const single = doc.numPages === 1;
|
||||
const gap = 16;
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.alignItems = 'center';
|
||||
container.style.gap = gap + 'px';
|
||||
container.style.padding = (single ? 0 : gap) + 'px 0';
|
||||
container.style.minHeight = single ? '100%' : '';
|
||||
container.style.justifyContent = single ? 'center' : '';
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const natural = page.getViewport({ scale: 1 });
|
||||
const scale = single
|
||||
? Math.min(width / natural.width, height / natural.height)
|
||||
: Math.min((width - 2 * gap) / natural.width, 1.6 * (96 / 72));
|
||||
const vp = page.getViewport({ scale: scale * dpr });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = Math.floor(vp.width / dpr) + 'px';
|
||||
canvas.style.height = Math.floor(vp.height / dpr) + 'px';
|
||||
canvas.style.background = '#fff';
|
||||
canvas.style.boxShadow = single ? '' : '0 2px 8px rgba(0,0,0,.4)';
|
||||
container.appendChild(canvas);
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
||||
page.cleanup();
|
||||
}
|
||||
return { pages: doc.numPages, rendered: count };
|
||||
} finally {
|
||||
// Frees the document and its worker-side data (pdf.js 6 has this on
|
||||
// the loading task, not the document).
|
||||
task.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -24,6 +24,7 @@
|
||||
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||
<script src="docx-render.js"></script>
|
||||
<script type="module" src="pdf-render.mjs"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="slide" id="a"></div>
|
||||
@@ -91,7 +92,7 @@
|
||||
async function prepare(layer, item) {
|
||||
layer.innerHTML = '';
|
||||
layer.scrollTop = 0;
|
||||
layer.classList.toggle('doc', item.type === 'docx');
|
||||
layer.classList.toggle('doc', item.type !== 'image');
|
||||
if (item.type === 'image') {
|
||||
const img = new Image();
|
||||
img.style.objectFit = state.options.fit;
|
||||
@@ -103,6 +104,14 @@
|
||||
layer.appendChild(img);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'pdf') {
|
||||
if (!window.KioskPdf) throw new Error('PDF renderer missing');
|
||||
const bytes = await getDocBytes(item);
|
||||
const box = document.createElement('div');
|
||||
layer.appendChild(box);
|
||||
await KioskPdf.render(bytes, box, window.innerWidth, window.innerHeight);
|
||||
return;
|
||||
}
|
||||
if (!KioskDocx.available()) throw new Error('Word document viewer missing');
|
||||
const bytes = await getDocBytes(item);
|
||||
const zoomEl = document.createElement('div');
|
||||
@@ -161,10 +170,11 @@
|
||||
// Free the old slide (a rendered document can be large) once faded out.
|
||||
setTimeout(() => { if (!old.classList.contains('show')) old.innerHTML = ''; },
|
||||
state.options.transition * 1000 + 100);
|
||||
const seconds = item.type === 'docx' ? state.options.docInterval : state.options.interval;
|
||||
if (item.type === 'docx') autoScroll(back, seconds);
|
||||
const isDoc = item.type !== 'image';
|
||||
const seconds = isDoc ? state.options.docInterval : state.options.interval;
|
||||
if (isDoc) autoScroll(back, seconds);
|
||||
// A lone document still cycles, so its scroll starts over from the top.
|
||||
if (order.length > 1 || item.type === 'docx') timer = setTimeout(next, seconds * 1000);
|
||||
if (order.length > 1 || isDoc) timer = setTimeout(next, seconds * 1000);
|
||||
}
|
||||
|
||||
window.kioskSlideshow = {
|
||||
@@ -182,7 +192,7 @@
|
||||
if (!s.items.length) {
|
||||
order = [];
|
||||
generation++;
|
||||
showMessage(s.exists ? `No images or documents in ${s.dir}` : `Folder not found: ${s.dir}`);
|
||||
showMessage(s.exists ? `No images, documents or PDFs in ${s.dir}` : `Folder not found: ${s.dir}`);
|
||||
return;
|
||||
}
|
||||
if (sameItems && !first) return;
|
||||
@@ -195,8 +205,8 @@
|
||||
pos = keep;
|
||||
clearTimeout(timer);
|
||||
const it = order[pos];
|
||||
const seconds = it.type === 'docx' ? s.options.docInterval : s.options.interval;
|
||||
if (order.length > 1 || it.type === 'docx') timer = setTimeout(next, seconds * 1000);
|
||||
const seconds = it.type !== 'image' ? s.options.docInterval : s.options.interval;
|
||||
if (order.length > 1 || it.type !== 'image') timer = setTimeout(next, seconds * 1000);
|
||||
} else {
|
||||
pos = -1;
|
||||
next();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// 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 and Word documents (.docx) in it,
|
||||
// full-screen slideshow of the images, Word documents (.docx) and PDFs 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
|
||||
@@ -12,12 +12,12 @@
|
||||
// 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
|
||||
// document (.docx/.pdf) 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,
|
||||
// "docInterval": 20, seconds per document - .docx or PDF (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
|
||||
@@ -32,7 +32,7 @@ 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 DOC_TYPES = { '.docx': 'docx', '.pdf': 'pdf' };
|
||||
const REFRESH_MS = 60000;
|
||||
const MAX_DOC_BYTES = 50 * 1024 * 1024;
|
||||
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||
@@ -101,10 +101,10 @@ function listItems(dir, recursive, depth = 0) {
|
||||
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)) {
|
||||
} else if (DOC_TYPES[ext]) {
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ }
|
||||
out.push({ path: full, type: 'docx', mtime });
|
||||
out.push({ path: full, type: DOC_TYPES[ext], mtime });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -157,7 +157,7 @@ function attach(webContents) {
|
||||
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;
|
||||
if (!current.items.some((it) => it.type !== 'image' && it.url === url)) return;
|
||||
const doc = readDoc(url);
|
||||
webContents.executeJavaScript(
|
||||
`window.kioskSlideshow&&window.kioskSlideshow.docData(${JSON.stringify(url)},${JSON.stringify(doc)})`,
|
||||
|
||||
+3
-2
@@ -122,8 +122,9 @@ provision_install_nodejs() {
|
||||
|
||||
provision_install_app() {
|
||||
echo "[4/10] Installing kiosk app..."
|
||||
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
||||
# *.mjs: pdf-render.mjs (pdf.js only ships as a JavaScript module).
|
||||
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.mjs "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
|
||||
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.mjs "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
|
||||
sudo chmod +x "$KIOSK_DIR/start.sh"
|
||||
|
||||
if offline_mode_active; then
|
||||
|
||||
Reference in New Issue
Block a user