pdf-next
A small, fast PDF, image and markdown viewer that reloads the moment the file changes —
built for the LaTeX and Typst compile loop, where you rebuild and want to see the result
without touching anything.

It is the desktop sibling of vscode-pdf Next
and shares its rendering approach: Mozilla PDF.js (pdfjs-dist@6.3.289) parsing in a
real worker thread, dark reading modes that recolour pages instead of filtering them, and reload
that survives a build deleting and recreating the file mid-compile.
What it does
-
Sharp rendering on high-density displays. Pages refresh when display density changes.
At high zoom, detail rendering skips off-screen drawing operations to reduce scrolling work.
-
Watches your file, on by default. Rebuild the PDF and the view updates within a second,
keeping your page, scroll position and zoom. The interval is in the toolbar — 1s, 2s, 3s, or
off. If the build deletes the file first, the last render stays on screen and a red dot
appears until the new file lands.
-
Never shows a half-written file. A build rewrites a PDF over hundreds of milliseconds and
the file's metadata changes the instant it starts, so a naive watcher reloads garbage. Before
reloading, pdf-next checks the file is complete: on Windows the writer normally holds an
exclusive lock, so simply opening it fails; on macOS and Linux nothing stops you reading a
partial file, so PDFs are also checked for their %%EOF trailer. If it is not ready, the
reload waits for the next tick.
-
Leaves the PDF available to build tools. The viewer keeps PDF bytes in memory and
closes the source file after reading. You can overwrite, replace, or delete it while
it is displayed. Release smoke tests check those operations in the running viewer.
-
Fits the window to the document. Open a paper and the window becomes the size of the page
itself, centred; open a wide figure and the window is wide. It only happens when you open a
file — rebuilds never move your window. With no file open, it stays a small drop target.
-
Trims the window to the page when you open a file. A PDF or figure comes up at 100% and
the window hugs it — no band of empty desk on a big screen. A figure larger than the screen,
such as a plot saved at 400 dpi, opens whole: it is scaled down until it fits. A PDF page
taller than the monitor clamps to the screen and scrolls. Rebuilds and tab switches leave the window where it is.
Ctrl+Shift+F keeps the window following the content after that.
-
Dock to any half of the screen — five toolbar buttons, or Ctrl+Shift+← / → / ↑ /
↓, fill the left, right, top or bottom half, so the document takes one half and your
editor keeps the other. Any dock switches to fit-width, because a half-screen window is for
reading in, and fitting a whole page into one just shrinks the text. The center button (or
Ctrl+Shift+Enter, or pressing the same edge again) undocks: the window hugs the page at
100%, centred. Your place in the document survives every move.
-
Fit the window to the content with Ctrl+Shift+F or the toolbar button — the other
direction from everything above. Set a figure to the size you want it and the window comes to
the picture: no padding, no border of background, the frame exactly on the edges. It stays on,
so zooming out brings the window in with it and zooming in pushes it back out, up to the size
of your screen. Choosing a fit preset from the zoom menu turns it off again, because fitting
the content to the window is the opposite instruction.
-
Tabs, without the memory. Open several files and they line up in a strip; ← / →,
Ctrl+Tab or Ctrl+1…9 move between them, Ctrl+W closes one. A tab is a path, not a
loaded document — only the file you are looking at is in memory, so six open papers cost what
one costs, and a background tab is read fresh from disk when you come back to it rather than
going stale. Switching costs a re-parse, against a worker that is already warm.
-
Walk a folder of figures. With a single file open, ← / → (or the toolbar arrows) step
through every image next to it, in reading order — fig2 before fig10, not after it. The
toolbar shows your position, and the window stays where it is instead of resizing on every
press. A rotate button next to the arrows turns the picture a quarter turn clockwise per
press; the next file comes up upright again.
-
PDF, images and markdown. .pdf, plus .png, .jpg, .webp and .avif, plus .md.
Images zoom too, with the same control and keys as a PDF.
-
Markdown, rendered or raw. A .md opens as a typeset reading column — GitHub-style
tables, task lists, footnotes and strikethrough included — parsed by
pulldown-cmark in Rust and sanitized by
ammonia before the HTML ever reaches the window,
so a hostile file cannot script anything. Ctrl+U (or the toolbar button) flips to the raw
text and back; zoom reflows the text rather than scaling a bitmap. Edit the file and the
view reloads within a second, keeping your scroll position — the same watch loop as PDFs.
-
Equations, typeset. $…$ and $$…$$ are TeX, turned into MathML by
pulldown-latex in the same Rust pass and set
in Latin Modern — no JavaScript math engine, nothing fetched, and the MathML meets the same
sanitizer as the prose.
-
Markdown typography: Rendered text is set in Latin Modern Roman at 17 px on a 38 em measure, hyphenated with text-wrap: pretty for natural word breaks. Zoom reflows the text rather than scaling it.
-
Ask and the Review panel. Select rendered text and create a Delete or Improve request. A review keeps the original quote, PDF page and context, stable ID, action, status and resolution in {stem}_review.json (paper.pdf → paper_review.json). Delete means “remove this passage from the source”; saving a request never changes a PDF or source file. Improve carries its instruction in comment. The Review panel shows each request and tracks it through open, applied and resolved. An agent records a successful source edit and build as applied; you verify the recompiled PDF before resolving it. Ctrl+Shift+R toggles the panel. − and + in the panel head change the comment size. Not available for images.
-
Copy the path or the name from the two buttons after zoom: the full path as a person would type it, or just the file name.
-
Reduce a PDF with the toolbar button next to Print. A pure-Rust pass (no Ghostscript) writes {stem}_reduced.pdf beside the original when it can shrink the file; the original stays untouched and the smaller copy opens as a tab.
-
Changed blocks light up on reload: When a Markdown file rewrites, changed and new paragraphs, headings, list items, tables and equations fade from a highlight over a few seconds. The comparison is by content: adding a paragraph marks only that one. Scroll position is kept.
-
Links go where you would expect. A #heading or footnote scrolls; a web link opens in
your browser; a relative link — [notes](other.md), [fig](fig1.png), the paper it
cites — opens as a new tab, if it is a kind pdf-next shows. Nothing ever navigates the
viewer itself.
Local images referenced by the file are deliberately not loaded: the viewer reads exactly
the files you opened, nothing next to them.
-
Follows your system dark mode, and can recolour PDF pages themselves. The mode button
cycles Clear → Night → Invert → Sepia, so plain white pages are always one press away;
Shift+click it to jump straight back to Clear from anywhere.
-
Text search with match counts, powered by PDF.js.
-
Opens at a page, or at a figure. --page 12, --find "Figure 3" and --dest results
say where to land, and the same thing can be written on the path the way a PDF link is —
paper.pdf#page=7&search=wake. For Markdown, --line 42 or #line=42 aim at the first block at or after that line; --find and #search= also work on Markdown. A file already open is aimed rather than opened twice, so a
running window jumps to the page you asked for; --no-focus hands a file over without
raising the window. The opened line prints the fragment that was understood (including line=N for Markdown), which is what
lets a script — or an agent — check rather than assume.
-
Print with Ctrl+P, to the system's own dialog — the real one, with your printer list,
page range, copies, duplex, paper size and scaling. pdf-next adds no print settings of its
own: it lays the document out for paper, then hands the window to macOS's print panel, the
GTK dialog, or WebView2's print preview, whichever the machine has. A PDF is re-rendered at
150 dpi on white, so what comes out is the page and not the screen — no toolbar, no dark
mode, no window chrome — and an A4 paper prints at true size on A4. Markdown reflows across
sheets as text, and an image gets a sheet to itself. A document whose pages are not all the
same size follows the first one, as it does in every other viewer. If the machine has nothing
to print to — no printer, or a stopped print service — it says so rather than opening nothing.
-
The title says which build you are running — paper.pdf — pdf-next 0.14.3 — so a bug report
can name a version without hunting for an about box.
-
Tells you when there is a newer version. A few seconds after launch the app asks
GitHub for the latest release, once; if it is newer, the last toolbar button lights up and a
press fetches the installer for your system — .exe, .dmg or .AppImage — in your
browser, from the repo's own Releases page. Pressing the button checks again on demand.
Nothing polls: that one request at launch is the only network access the app makes on its
own, downloads only ever happen on a press, and GitHub is the only host it can reach.
-
Keyboard first: j/k scroll, n/p pages, g/G first and last, +/- zoom,
←/→ tabs (or folder, with one file open), Ctrl+W close, Ctrl+F find, Ctrl+R reload,
Ctrl+O open, Ctrl+P print, Ctrl+U raw markdown, Ctrl+Shift+A ask, Enter (with a selection) add a review, Ctrl+Shift+N add a review, Ctrl+Shift+R Review panel,
Ctrl+Shift+F fit window to content,
Ctrl+Shift+←/→/↑/↓ dock to a screen half, Ctrl+Shift+Enter undock.
Install
| System | Command |
|---|
| Windows | Download the .exe installer (winget submission pending) |
| macOS | brew install --cask ricardofrantz/tap/pdf-next |
| Debian, Ubuntu | add the repository below, then sudo apt install pdf-next |
macOS, with Homebrew
The cask lives in a personal tap rather than Homebrew's own repository, so the
tap is named once. Writing it in full does that for you:
brew install --cask ricardofrantz/tap/pdf-next
Or add the tap first and use the short name from then on:
brew tap ricardofrantz/tap
brew install --cask pdf-next
Afterwards:
brew upgrade --cask pdf-next # to the newest release
brew uninstall --cask pdf-next # and `brew untap ricardofrantz/tap` to forget the tap
A workflow in the tap reads this repository's releases once a day, so a new
version arrives there within a day of its tag rather than the moment it lands.
Windows, with winget
The first winget submission
is awaiting review. Until it is merged, use the .exe installer from
Releases.
After the package is accepted:
winget install RicardoFrantz.pdf-next
winget upgrade RicardoFrantz.pdf-next
winget uninstall RicardoFrantz.pdf-next
Debian and Ubuntu, with apt
One line:
curl -fsSL https://raw.githubusercontent.com/ricardofrantz/pdf-next/main/tools/install-apt.sh | sudo sh
Or add the signed repository yourself:
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://ricardofrantz.github.io/pdf-next/pdf-next.asc \
| sudo tee /etc/apt/keyrings/pdf-next.asc > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/pdf-next.asc] https://ricardofrantz.github.io/pdf-next stable main" \
| sudo tee /etc/apt/sources.list.d/pdf-next.list > /dev/null
sudo apt update && sudo apt install pdf-next
Then sudo apt upgrade pdf-next for a new version and sudo apt remove pdf-next to take it off. Every version stays in the repository, so
sudo apt install pdf-next=0.9.0 still installs that one.
The file itself
Or take it from Releases:
.exe or .msi for Windows, a universal .dmg for macOS (Intel and Apple
Silicon in one file), .deb, .rpm or .AppImage (chmod +x and run) for
Linux.
The Windows .exe is one click after SmartScreen: it copies into
%LOCALAPPDATA%\pdf-next (no administrator prompt), shows the progress, and
opens. There is no Next / I Agree / Finish. /W on the command line brings
the old wizard back if you need it; /S stays silent for the Store.
The builds are not code signed yet, so Windows SmartScreen warns once — More
info → Run anyway — and macOS quarantines the app and refuses its first
launch, whether it came from Homebrew or the .dmg. Let that copy through
once:
xattr -dr com.apple.quarantine /Applications/pdf-next.app
Signing, and how each of these channels is published, are described in
docs/distribution.md; what the app does with the
network, in PRIVACY.md.
Then open a file by double-clicking it, dragging it onto the window, pressing Ctrl+O, or
passing a path:
pdf-next paper.pdf
Command line
pdf-next paper.pdf --night --left # dark pages, filling the left half
pdf-next figure.png --invert # inverted, for a white-background plot
pdf-next thesis.pdf --sepia --poll 3 # warm paper, check for rebuilds every 3s
pdf-next NOTES.md --sepia # markdown as warm paper, reloading as you write
pdf-next paper.pdf supp.pdf fig1.png # three tabs, the first one showing
pdf-next paper.pdf --page 12 # open at page 12
pdf-next paper.pdf --find "Figure 3" # open at the first match, highlighted
pdf-next 'paper.pdf#page=7&search=wake' # the same, written as a link
| Flag | Effect |
|---|
--left | Dock to the left half of the screen (implies fit-width). |
--right | Dock to the right half (implies fit-width). |
--top / --bottom | Dock to the top or bottom half (implies fit-width). |
--night / --dark | Dark pages, light text. |
--sepia / --reader | Warm paper. |
--invert | Invert the page, for scans and white-background figures. |
--plain / --light | Original page colors. |
--mode | Same as the above, by name. |
--page | Open at that page. |
--find | Open at the first match, highlighted. |
--dest | Open at a named destination. |
--no-focus | Hand the file over without raising the window. |
--poll | Watch interval; 0 turns watching off. |
--wait | Stay attached to the terminal until the window closes (macOS and Linux — see below). |
--help, --version | Print and exit. |
Appearance flags style that window only — they do not change your saved default.
Run it again with another file while it is open and the file joins the running window as
a new tab — the second command returns at once instead of opening a second viewer.
From scripts and agents
pdf-next behaves like a command-line tool, so a script — or a coding agent — can call it
without guessing:
pdf-next report.pdf # prints "opened /abs/path/report.pdf" and returns at once
pdf-next --help # usage, exit 0
pdf-next missing.pdf # "pdf-next: no such file: missing.pdf", exit 1
pdf-next --bogus # "unknown flag --bogus (try --help)", exit 2
Pointing at a place in a document. --page 12, --find "Figure 3" and --dest intro
say where to land; the same thing can be written on the path as a fragment, in the form PDF
links have always used — paper.pdf#page=7, #nameddest=results, #search=Figure%203,
joined with &. Quote it: a shell reads # as the start of a comment. The opened line
prints the fragment that was understood, so a caller can check rather than assume:
pdf-next 'paper.pdf#page=7&search=wake' --no-focus
# opened /abs/path/paper.pdf#page=7&search=wake
A page past the end lands on the last page and says so. A --find with no match leaves the
document where it was, with the find bar showing the query and no results. The three flags
speak about the file named before them, or about the first file when they come first, so
several documents can be aimed at in one command.
If the file is already open in a tab, it is aimed rather than opened a second time — a
running window jumps to the page you asked for. --no-focus hands the file over without
raising the window, which is what you want when a script opens six figures in a row.
Exit status is 0 when the file was launched or handed to the running window, 1 for a
file that does not exist or is not a kind pdf-next can show, 2 for a command line it
could not understand. Relative paths
resolve against the caller's directory, including when they are forwarded to a running
instance. On macOS and Linux the launched process detaches by default (own process group,
so a shell tool's timeout cannot take the window down); pass --wait to hold the terminal,
as a compile loop might want. On Windows a GUI executable never holds the console.
macOS: a .app is not on your PATH. Either use the standard idiom — open -a pdf-next report.pdf — which delivers the file to the app (or the running window) the way Finder does,
or make a one-line shim once:
printf '#!/bin/sh\nexec /Applications/pdf-next.app/Contents/MacOS/pdf-next "$@"\n' \
> /usr/local/bin/pdf-next && chmod +x /usr/local/bin/pdf-next
Then which pdf-next, pdf-next --help and pdf-next report.pdf all work as above. If your
agent has a project instructions file, one line — "open PDFs with pdf-next ; use
open -a pdf-next on macOS if the shim is missing" — saves it rediscovering this each time.
PDF review tasks for agents
The review sidecar is a task list for editing the project source. The intended loop is:
- Read the current tasks with
pdf-next reviews list paper.pdf.
- Find the selected PDF quote in the LaTeX entry point or its included chapter files. Use its
quote, surrounding context and PDF position together. An optional SyncTeX source hint is a
candidate to check against the actual source.
- Edit the relevant
.tex source, then compile the project's entry point.
- After a successful build, record the source path, build result and resolution on the same
review ID. Leave failed builds open. Mark the task
applied only after compilation succeeds.
- Leave it
applied until the reader checks the rendered PDF and marks it resolved.
For example, save this object in patch.json after checking the current revision:
{
"status": "applied",
"resolution": "Reworded the paragraph and compiled paper.tex successfully.",
"source": { "file": "chapters/method.tex" },
"build": { "success": true, "command": "latexmk -pdf paper.tex" }
}
Then update only that record:
pdf-next reviews list paper.pdf
pdf-next reviews update paper.pdf --id r12 --patch patch.json --expected-revision 8
Use the revision printed by list for --expected-revision. A concurrent update causes a
conflict; reread the store and prepare the patch again instead of overwriting it. --dry-run
checks a patch and prints the proposed JSON without saving it. The source field uses the
key file for the path actually edited. A SyncTeX hint in that field carries
method: "synctex" and verified: false; replace it with the source you verified.
Updates preserve unrelated fields and records. Each supplied field replaces its
previous value; include the complete object when replacing anchor, source, or
build so its other keys survive. The sidecar format and a complete record example are in
docs/_review.json.
Use the CLI mutation command instead of editing the JSON directly. A raw writer must preserve
IDs and unknown fields, write atomically, and check the sidecar revision; writers that skip
that protocol can overwrite concurrent changes.
The PDF quote is a rendered-text target, not a .tex line number. LaTeX commands, macros,
hyphenation, equations and included files can change how text appears. Check the surrounding
source before editing; an equation symbol needs its equation context and position, so do not
copy a symbol in isolation. Record the source file that actually changed. A disappeared quote
does not prove that a Delete request succeeded, and completing a task never removes its record.
Security
The threat model is the obvious one: you open a PDF someone sent you, and the attacker controls
every byte of it.
- The window cannot navigate away from the app. A link annotation in a malicious PDF used to
be able to replace the entire viewer with an attacker-controlled page — in a window with no
address bar — and a link pointing back at the asset protocol would be served as HTML at a
local origin, which Tauri trusts with IPC. A navigation guard now rejects anything that is
not the app's own origin, and external links in documents are inert (internal ones, like a
table of contents, still work).
- The webview can read exactly the files you opened. Document bytes are served by a
purpose-built
doc:// protocol whose handler checks every request against the set of files
you opened this session — an allowlist in our own Rust code, populated only by the open
path. Review commands also access the adjacent review sidecar and query an optional
local SyncTeX mapping; source hints must point to .tex files inside the PDF folder.
- Markdown is sanitized before it exists as HTML. A
.md is parsed and scrubbed by
ammonia on the Rust side; scripts, event handlers and javascript: URLs never cross into
the window, images are stripped so a file cannot probe your disk, and the CSP forbids
inline script besides.
- One network host, on request only. The CSP names
api.github.com and nothing else, so
the update check can ask for the latest release when you press the button — and an injected
script has nowhere else to talk to. The download itself is opened in your browser through a
Rust command that accepts only URLs under this project's Releases; the webview cannot ask
the OS to open anything else.
- Two permissions total — listen and unlisten for events. Everything else, including path
resolution and the file dialog, is driven from Rust where the frontend cannot reach it.
- No
eval, no WASM execution, no plugins, no framing, and PDF scripting is off, so a document
cannot execute anything on its own.
Memory, measured not claimed
Numbers from Windows 11, WebView2 151, whole process tree, the 15-page Attention Is All You
Need paper:
| Working set | Private bytes |
|---|
| Window open, no document | 376 MB | 189 MB |
| 15-page PDF open | 431 MB | 257 MB |
Be clear about what this means. The document costs about 55–70 MB; everything else is the
WebView2 (Chromium) floor, which is the same floor every Chromium-based app pays and is partly
shared memory counted against every process using it. The binary is ~5 MB and starts instantly,
but "low memory" here means lower than Electron, not lower than a native viewer — Electron
would ship its own copy of Chromium on top of this. A native renderer such as pdfium would be
dramatically lighter and is the honest alternative if resident memory is the only thing you
care about.
Linux (WebKitGTK) and macOS (WKWebView) use different engines with different, usually smaller,
floors — those are not measured yet.
Reloading used to leak badly. Each reload dropped the previous PDF.js document without
destroying it, which kept its worker thread, font and image caches and rendered canvases alive:
77 MB per reload, unbounded, so an afternoon of LaTeX would reach several gigabytes. Now the
document is torn down, one worker is shared for the life of the process instead of one spawned
per reload, page views free their canvases instead of waiting for the collector, and reloads are
skipped entirely while the window is hidden.
What remains scales with the document, not with the number of reloads. The ~10 MB-per-reload
growth that used to point at the webview caching each cache-busted URL is gone: documents are
served from a purpose-built doc:// protocol with Cache-Control: no-store, so no revision
ever enters the HTTP cache, and the asset protocol has been dropped entirely. On top of that,
hiding the window now also releases the PDF.js font and image caches (they rebuild lazily when
you come back), and a background tab holds neither a decoded image nor a rendered markdown DOM.
Building
Needs Rust and, on Windows, the MSVC build tools:
bun install
bun run dev # dev window with reload
bun run build # installers into src-tauri/target/release/bundle
On Ubuntu, install the webview dependencies first:
sudo apt-get install libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
Tests
cargo test --manifest-path src-tauri/Cargo.toml # the Rust side
node tools/check_frontend.mjs # the frontend contracts
bun run build && node tools/smoke.mjs # open every fixture, for real
check_frontend.mjs enforces the invariants that are easy to break silently: a real PDF.js
worker, streaming instead of whole-file reads, the canvas budget, the one-second poll, and the
mid-build gap behaviour.
tools/smoke.mjs launches the binary that was just built, once per file in tests/fixtures,
with PDF_NEXT_SMOKE=1 in the environment. In that mode the app answers for itself: it waits
for a page to be drawn with pixels in it, then reports through a Tauri command and exits on
that answer, so a window that opens and stays empty fails the run instead of passing it.
PDF_NEXT_SMOKE_TIMEOUT bounds a launch that never reports; PDF_NEXT_BIN names a binary
somewhere else.
The fixtures are built by python3 tests/make_fixtures.py, which writes the same bytes on
every machine, so a change to them is a change someone made.
All three run on Windows, macOS and Linux for every push, and a release stays a draft until
each platform's bundle has opened a document.
Scope
A viewer, not an editor. No annotations, no forms, no editing, no cloud. It opens a file, keeps
it current, and gets out of the way.
MIT licensed. Built on Mozilla PDF.js and
Tauri.
Disclaimer
This software is provided "as is", without warranty of any kind. To the extent
permitted by law, the authors and contributors are not liable for any damage, loss
or claim arising from its use or misuse. You are responsible for how you use it and
for following the laws and rules that apply to you. The full terms are in
LICENSE.
pdf-next is not affiliated with or endorsed by Mozilla. PDF.js is © Mozilla and
contributors, under the Apache License 2.0.