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:
Claude
2026-10-03 15:43:05 +00:00
parent 4b85ae59fc
commit fb4ee6fa62
6 changed files with 107 additions and 24 deletions
+2 -1
View File
@@ -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"
}
}
+71
View File
@@ -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();
}
},
};
+17 -7
View File
@@ -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();
+7 -7
View File
@@ -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)})`,