From fb4ee6fa62b70dfc51754637835f3d8082990fae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:43:05 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 14 ++++---- kiosk-app/package.json | 3 +- kiosk-app/pdf-render.mjs | 71 ++++++++++++++++++++++++++++++++++++++++ kiosk-app/slideshow.html | 24 ++++++++++---- kiosk-app/slideshow.js | 14 ++++---- lib/provision.sh | 5 +-- 6 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 kiosk-app/pdf-render.mjs diff --git a/Readme.md b/Readme.md index 9733385..25d58ff 100644 --- a/Readme.md +++ b/Readme.md @@ -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`). diff --git a/kiosk-app/package.json b/kiosk-app/package.json index c472b2b..9324094 100644 --- a/kiosk-app/package.json +++ b/kiosk-app/package.json @@ -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" } } diff --git a/kiosk-app/pdf-render.mjs b/kiosk-app/pdf-render.mjs new file mode 100644 index 0000000..63472ec --- /dev/null +++ b/kiosk-app/pdf-render.mjs @@ -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(); + } + }, +}; diff --git a/kiosk-app/slideshow.html b/kiosk-app/slideshow.html index 8b03460..bb83664 100644 --- a/kiosk-app/slideshow.html +++ b/kiosk-app/slideshow.html @@ -24,6 +24,7 @@ +
@@ -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(); diff --git a/kiosk-app/slideshow.js b/kiosk-app/slideshow.js index 30acf6f..708d5d3 100644 --- a/kiosk-app/slideshow.js +++ b/kiosk-app/slideshow.js @@ -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)})`, diff --git a/lib/provision.sh b/lib/provision.sh index 85067ec..bf774b9 100644 --- a/lib/provision.sh +++ b/lib/provision.sh @@ -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