PDF Presenter
AI-assisted project. This codebase was created with Claude
(Anthropic), directed and reviewed by a human author — including architecture,
implementation, and documentation. Check it over before you rely on it for a show.
A presenter that does one job. Open a PDF and you get a presenter view on one screen (Now and
Next, plus a strip of slide thumbnails you can click) and a clean fullscreen output on the
second. It doesn't do a notes panel, NDI or network integrations. It's a stripped-down sibling of
presentation-commander-client,
keeping only its proven pdf.js rendering pipeline and its multi-window/display-picker
pattern.
It comes in two forms, from this one repo. PDF Presenter is the desktop app, with the whole
feature set, OSC and the desktop integrations included. PDF Presenter Lite is the hosted
build at
pdf-presenter-lite.stoatworks-labs.com:
the same presenter view in a browser tab with nothing to install, minus the bits a web page
can't do. That difference is all the "Lite" means; the two share their entire UI.
A 48-second tour. That's the app itself presenting an actual PDF, driven over its own OSC
control surface.
Download
v1.7.3 — prebuilt for macOS, Windows and Linux. Pick your platform:
All builds, checksums and release notes: github.com/stoatworks-labs/pdf-presenter/releases.
macOS builds are signed and notarised and open normally. The Windows builds are unsigned, so SmartScreen warns once — see Windows SmartScreen & Defender Firewall for the one-time click-through.
v1.3.1 predates the rename. It was released as PDF Presenter Lite, so its
files are named pdf-presenter-lite-1.3.1-… and it installs as
PDF Presenter Lite.app / PDF Presenter Lite.exe. Everything below describes
the app by its current name, which it has carried since v1.4.0. The hosted
build keeps the old name for good: see
Run it in a browser.
What it does
- Open a PDF: it renders locally with pdf.js, with no other slide-source integrations
- Presenter view: a "Now" and "Next" preview side by side, with a horizontal strip of
thumbnails for every slide below them. Click any thumbnail to jump straight to that slide
- Keyboard navigation: Left/Right, Up/Down and Page Up/Page Down all move a slide, and
Space also advances. The same keys work whether the presenter view or the fullscreen Output
window has focus, so a presentation clicker keeps working on a single-display machine, or
after a stray click moves focus to the Output window
- Mouse navigation: a Previous/Next transport bar below the thumbnail strip, and clicking
the "Next" preview itself jumps straight to it
- Internal PDF link navigation: links authored into the PDF itself (a table of contents,
"back to agenda" links exported from PowerPoint/Keynote/Google Slides) become clickable on
the "Now" slide and jump straight to their target page. It reads link annotations through
pdf.js and resolves each destination to a page number, whether it's a named or an explicit
destination. Only internal same-document links are wired up; external URLs are left alone
- Fullscreen Output window: a second, chrome-free window showing just the current slide,
for a projector or confidence monitor. Pick which connected display it opens on from a
dropdown next to the toggle button. The two windows have different names in the window
switcher, "PDF Presenter — Control" and "PDF Presenter — Output ()"
- Screen blanking: press
B or W to cut the Output window to solid black or white
without losing your place, the same as PowerPoint's presenter shortcuts. Press again to
bring the slide back
- Hide cursor on Output: an optional checkbox that hides the OS mouse cursor whenever it's
over the Output window, so the audience-facing screen stays clean
- OSC control: a UDP OSC address space (slide navigation, black/white, Output open/close,
system enable/disable) at
/pdfpresenter/.... It's plain UDP rather than a Windows COM
add-in, so it works on every platform this app ships for. A Bitfocus Companion module ships
alongside it,
companion-module-pdf-presenter-lite,
for driving it from a Stream Deck or any other Companion surface. An optional, off-by-default
"watched folder" feature lets OSC open a specific PDF by filename without a dialog, which is
handy for a button wall that loads a particular deck on cue. Sections come from the PDF's own
top-level outline/bookmarks, and goto/section jumps straight to one.
mirrors the presenter's mouse position over the "Now"
preview onto the Output window as a glowing dot, like PowerPoint's own laser pointer.
renders the current slide and sets it as the desktop
wallpaper on every connected monitor. macOS and Windows are fully covered; Linux is
GNOME-only
Run it in a browser, with nothing installed — PDF Presenter Lite
Everything above renders with pdf.js (the desktop app never did that part natively), so the
presenter view works just as well as a hosted web app with no backend at all. Build it with
npm run static:build and publish the result as static assets (a Cloudflare Worker, in my
case).
This build calls itself PDF Presenter Lite. The "Lite" is the difference in the table
below, and the name is in the app's own titlebar, so an operator can tell at a glance which one
is on the screen.
Your PDF is never uploaded. There's no server and no upload endpoint: the page reads the
file from your own disk, and the deck, the thumbnails and the Output window all stay inside
your browser. It's live at
pdf-presenter-lite.stoatworks-labs.com.
A 47-second tour of the hosted build, filmed at that address: a deck opened from disk, Now
and Next, the thumbnail strip, then the Output window on top of it. The deck was made up for
these videos, so nobody else's slides appear in it.
| Desktop app | Browser |
|---|
| Presenter view, thumbnails, transport | ✅ | ✅ |
| Fullscreen Output window | ✅ chosen display, opened fullscreen | ✅ popup you place, click to fullscreen |
| Keyboard / clicker, incl. from the Output window | ✅ | ✅ |
| Screen blanking, laser pointer, hide cursor | ✅ | ✅ |
| Internal PDF links, sections, auto-advance | ✅ | ✅ |
| OSC control (+ Companion module) | ✅ | ❌ |
| Watched folder | ✅ | ❌ |
| Set as default PDF app, wallpaper export | ✅ | ❌ |
| Diagnostics bundle | ✅ | ❌ |
The missing bits all come down to the same limitation: a web page has no UDP socket, no
path-addressable filesystem and no say over the desktop it runs on. The browser build hides
those controls rather than showing buttons that can't work. If you drive the show from a
Stream Deck, use the desktop app.
Two differences worth knowing about the browser Output window. It opens as a pop-up, so
allow pop-ups for the site. And it opens as a normal window rather than fullscreen: only a
gesture inside a window can make that window fullscreen, so it shows a "Click for fullscreen"
prompt, and there's no getting out of that click. Nothing the opener does can stand in for it.
On Chromium it puts itself on your second display, which needs the Window Management
permission. Refuse that and the window opens wherever the browser would have put it, and you
move it yourself. Either way, the click's the same.
Architecture
Two renderer views load from the same bundle, picked by a mode query param (the same pattern
as the Client Node's Program Out window):
App.tsx is the presenter view. It loads the PDF, owns currentPage/totalPages, renders
Now/Next and the thumbnails, and pushes { data, currentPage } to the Output window whenever
it changes.
Output.tsx is the fullscreen window. It pulls the current state with output:get-state
once its own listener is mounted, rather than relying only on a live push, which can race and
get dropped without a word if the window's still loading when the presenter pushes. After
that it stays in sync through output:state pushes for as long as it's open.
pdf.ts (the PDF loading/rendering helpers) is copied verbatim from
presentation-commander-client, with the same render-queue-per-canvas serialisation to avoid
the transform-corruption bug documented there.
Both views talk to one interface, PresenterApi in
src/shared/api.ts, and two backends implement it:
src/preload/index.ts: the Electron IPC bridge.
src/web/browserApi.ts: the hosted build. The Output window is a
window.open pop-up and the two windows talk over a BroadcastChannel, which stands in for
everything the main process does between them: forwarding state and laser position,
relaying transport keys pressed while the Output has focus, and holding the last state so an
Output that finishes loading after a push can pull it (the race mentioned above).
Each backend declares what it can do through capabilities, and the components branch on
that, never on "is this Electron".
Status
Field proven, both builds. The desktop app and the hosted browser build (PDF Presenter
Lite) have each been used on real events, not just checked on the bench.
Before that, I built and checked the desktop app end to end: opening a PDF, thumbnail-click
navigation, arrow-key navigation and the fullscreen Output window all worked against a real
multi-page PDF. Testing turned up a real race condition in the initial state hand-off, which
got fixed.
The browser build had the same bench test first, on a dual-display setup against a real
multi-page PDF: the deck loads and renders, the presenter view and thumbnails work, state and
laser position reach a separate Output window, transport keys pressed in the Output window
drive the control window, an Output window loaded after a push pulls the current state, and
the Output window opens on the second display by itself.
It opens there as a normal window, not fullscreen. That part takes one click, and has to. Only
a gesture inside the window that's going fullscreen can put it there, so no amount of work in
the opener can skip it; the prompt in the Output window is that gesture.
Inspiration & prior art
The OSC control (and, indirectly, the wallpaper export) came from looking at how existing
remote PowerPoint control tools work. None of their code is reused here (this app doesn't
depend on PowerPoint at all, and both are closed-source anyway), but I'd rather be
upfront about where the ideas came from:
- OSCPoint: a Windows PowerPoint add-in with an OSC
API. It's closed-source, so nothing was copied from it. Its public documentation
(
ACTIONS.md/FEEDBACKS.md/EVENTS.md) was read to design a comparable address space and
the two-port (action-in/feedback-out) architecture this app uses. That address space started
out mirroring OSCPoint's own (/oscpoint/..., matching ports) so it would drop straight into
Companion. It's since been renamed to /pdfpresenter/... and cut loose from OSCPoint
entirely, now that this app has its own Companion module.
- Iris Down Remote Show Control: an older, separate
commercial PowerPoint add-in that takes plain ASCII text commands (
NEXT, PREV, GO,
RUNCURRENT, SETBG, …) over UDP/TCP, with no feedback channel at all. Its command set
overlaps in concept with several features here: slide navigation, starting from the current
slide, and in particular SETBG's "set desktop wallpaper to the current slide," which is
what this app's wallpaper export does too. Nothing was read from its source (it isn't
public) or its wire format (plain text vs. this app's OSC). The overlap is in what the
features do, not how they're built.
Project Setup
Install
npm install
Development
npm run dev
Build
npm run build
npm run build:mac # or build:win / build:linux
Hosted (browser) target
npm run preview:static # vite dev server on :5185, no backend
npm run static:build # production build into out-static/
npm run deploy:static # build, then publish the Worker (needs Cloudflare creds)
The Output window is /?mode=output, the same URL the pop-up opens.
Windows SmartScreen & Defender Firewall
macOS builds are Developer ID-signed and notarised by Apple, so they open normally, with no
Gatekeeper warning and no quarantine step. The Windows binaries aren't code-signed, so
Windows still warns you the first time.
- Windows: SmartScreen shows "Windows protected your PC" →
More info → Run anyway.
- Windows Defender Firewall: first launch pops up "Allow PDF Presenter to
communicate on these networks". Tick Private (and Domain on a managed
network), because PDF Presenter needs it to receive OSC from Bitfocus Companion. Deny it
and the Companion buttons will look as if they're working, but nothing will happen.
- Linux: no signing gate.
Per-artifact steps, self-signing, checksum verification and the Defender Firewall reset
procedure: docs/UNSIGNED.md.
This project is built on other people's work — see ATTRIBUTIONS.md.