Read PDFs in your terminal without your fan kicking on.
Pixel-perfect pages via the kitty graphics protocol, vim keys, indexed search, highlights stored in the PDF. Single-digit CPU during sustained scroll on a 600-page book.
Why this exists
Most terminal PDF "readers" either draw halfblocks and pretend that's reading, or use real graphics protocols but burn CPU as if you were watching video — a held-j on a 600-page book pegs a core, the laptop discharges while plugged in, the fan kicks on.
termpdf-rs is the inverse: real pixel-perfect images via the kitty graphics protocol, but the scroll and idle paths are budgeted ruthlessly. Idle with a PDF open emits effectively no bytes on the pty. A sustained scroll burst on a 600-page book lands in single-digit CPU.
What you get on day one
▮Pixel-perfect pages
Real PNG-quality renders via the kitty graphics protocol — Kitty, Ghostty, WezTerm. No halfblock guesswork.
viVim keys, vim text-objects
j/k/gg/G; viw/vis/vip on PDF text; marks m{a-z}/'{a-z}; Ctrl-o/Ctrl-i jumplist.
⌕Indexed search
First search builds a back-index in the background and persists it. Subsequent opens hit instantly.
▰Highlights live in the PDF
Native PDF annotations — they travel with the file and render in Adobe, Preview, Sioyek, zathura.
◐Color-aware dark mode
Luminance-only HSL inversion across the whole pixmap — embedded images inverted too, hue preserved. More →
→fVimium-style link-follow
f overlays 1-2 char hints on every clickable link. Type the hint to jump or open.
Power efficiency, measured
PDF reading is a low-frequency activity — pages turn at human speed, not video speed — so a reader that pegs CPU on scroll is wasting battery on nothing. Idle redraws are gated on a dirty flag: when you do nothing, the binary writes nothing to the pty. Held-key bursts defer cold pdfium renders entirely; a single settle redraw catches up when input goes idle.
| Metric | termpdf-rs | Chrome | Ratio |
|---|---|---|---|
| Idle CPU% | 3.2% | 17.1% | 5.3× |
| Idle RSS | 71 MB | 558 MB | 7.9× |
| Metric | termpdf-rs + Ghostty | Firefox | Ratio |
|---|---|---|---|
| Scroll CPU% (median) | 7.5% | 66.0% | 8.8× |
| Scroll CPU% (max) | 8.0% | 74.0% | 9.3× |
Numbers come from scripts/bench-vs-browser.sh and monitor-scroll.sh in the repo. Run them on your machine — you'll get something close, modulo your CPU and terminal.
What makes it fast
▷Per-page kitty transmit
Each PDF page becomes its own kitty image_id with cached f=24,o=z RGB+zlib payload. Scrolling re-emits a few hundred bytes of unicode-placeholder cells per frame instead of re-encoding a multi-MB canvas.
∅Idle = zero bytes
Frames are gated on a dirty flag. With no input and no settle work pending, the binary writes nothing to the pty. A PDF open in the background pulls effectively zero CPU.
⊕Synchronized output
Every frame is wrapped in DECSET 2026 (BSU/ESU). The terminal commits the whole frame atomically — no partial-paint flicker, even mid-scroll on a 600-page book.
⏃Cold renders deferred
Held-j bursts skip pdfium renders for off-screen pages until input goes idle. A single settle redraw catches up at 16 ms cadence so the next press isn't blocked behind a 20 ms pdfium decode.
⌬Worker-thread rendering
Pdfium decodes happen on a background thread; the UI thread only ever copies the finished pixmap into the page cache. j/k feels instant because the cache is usually warm by the time you press.
⌗Persistent disk cache
Rendered page bitmaps and the search back-index are sandboxed under $XDG_CACHE_HOME/termpdf-rs/. Re-opening a book skips the cold render entirely; first /<query> after a reopen hits the index instantly.
⊞Image-storage budget
Tracks decoded-RGBA bytes residency in the terminal's image store and evicts the page furthest from the viewport when the budget tightens. Stops Ghostty from quietly dropping pages out from under us mid-scroll.
▤Hot-path discipline
Per-frame allocations are hoisted into reusable scratch buffers. Page revisions are O(1) counters, not FNV-hashed signatures. Encoded payloads stream into 12 KB stack chunks instead of allocating a 24 MB intermediate.
◑Settle vs rapid scroll
The redraw scheduler distinguishes "user is mid-burst" from "burst just ended." Rapid scroll defers cold renders without forcing a redraw; the settle catch-up only fires when input has actually stopped.
Dark mode that doesn't lie about color
Most PDF dark modes are a CSS-style stencil: text inverts, background inverts, embedded images stay as is. You scroll into a chapter and a 1500-pixel-square white photo blasts your eyes. termpdf-rs treats the whole page — text, charts, screenshots, scanned figures — as one pixmap and remaps it together. A dark page is dark, end to end.
The remap is luminance-only, not a naive 255 - x per-channel invert. Each pixel converts to HSL, the lightness flips around 0.5, and the hue stays put. Result: red errors stay red, blue charts stay blue, syntax-highlighted code still parses — just tonally remapped onto a dark canvas.
▣Whole-page inversion
Embedded images get the same luminance flip the text does. No more bright rectangles ambushing you mid-chapter.
▦Hue preserved
Naive RGB invert turns red into cyan and blue into orange. termpdf-rs flips lightness in HSL space — saturated colors stay close to where they started.
▥Grayscale fast path
For body-text PDFs (90%+ grayscale pixels) the algorithm short-circuits to per-channel 255-x. Cuts the dark-render cost from ~50 ms to ~12 ms on a typical 1500×1900 page.
Toggle with d, or pin via --dark on the command line. The choice persists per PDF — start a paper light at your desk, finish it in bed without re-toggling.
Install
Linux x86_64, terminal that speaks the kitty graphics protocol (Kitty, Ghostty, WezTerm). No pre-built binaries yet — clone, vendor pdfium, build.
setup.sh downloads the pinned libpdfium.so (~7.5 MB) into the repo. Then a stock release build.
git clone https://github.com/amanagr/termpdf-rs
cd termpdf-rs
./setup.sh
cargo build --release
./target/release/termpdf paper.pdf
# alias for daily use
alias pdf='~/termpdf-rs/target/release/termpdf'
Not on crates.io yet — pdfium is a runtime native dependency that needs vendoring. Use the source path above. A crates.io release lands once the libpdfium loader can locate a system-installed library across distros.
Inside tmux, enable graphics passthrough once:
tmux set -g allow-passthrough on
The binary prints a one-time hint if it detects tmux without passthrough enabled.
Five keys to get started
| Key | What it does |
|---|---|
| j / k | Next / prev page |
| Space | Scroll one screen down (less-style) |
| o | Open the table-of-contents panel — / to fuzzy-filter |
| v | Visual mode → viw/vis/vip → y to highlight |
| f | Link-follow — type the hint over any clickable link |
Press ? in the binary for the full overlay. The full keymap also lives at /keys/.
Full keymap
Navigate
| j / k | next / prev page |
| Space / b | one screen down / up (less-style) |
| Ctrl-d / Ctrl-u | half-screen down / up |
| gg / G | first / last page |
| :n / N G | jump to page N |
| ]] / [[ | next / prev outline section |
| m{a-z} / '{a-z} | set / jump to mark (persisted per PDF) |
| Ctrl-o / Ctrl-i | jumplist back / forward |
| + / - / 0 | zoom in / out / reset |
| d | toggle color-aware dark mode |
| f | link-follow hint mode |
Highlight & quote (Visual — v)
| h j k l · w b e · 0 ^ $ | move the caret like in vim |
| iw / is / ip | select inner word / sentence / paragraph |
| V / Ctrl-v | linewise / blockwise selection |
| c | cycle highlight color |
| y | save highlight + copy plain text |
| Y | copy plain text only (no highlight) |
| gy | copy as Markdown blockquote with citation |
| click + drag | highlight with the mouse |
| x (Normal) | delete last highlight on current page |
Search, TOC, export
| /<query> then n / N | search · next / prev match (indexed) |
| :nohl | clear search results |
| o | open TOC panel — / to filter, Enter jumps |
| :export [path] | dump highlights as a Markdown notes file |
FAQ
Does this work over SSH?
Yes, if your local terminal speaks the kitty graphics protocol and the remote shell forwards bytes cleanly. Latency-bound on slow links because each page is a PNG-sized payload; on a LAN it's fine.
What about Wayland / X11?
Doesn't care. termpdf-rs renders into your terminal, not into the display server. If your terminal runs, this runs.
Does it support encrypted / password-protected PDFs?
Not yet. Decrypt first with qpdf --decrypt locked.pdf unlocked.pdf and open the result. Tracked as a known limitation.
Can I use this in tmux?
Yes. Run tmux set -g allow-passthrough on once so tmux forwards the kitty graphics escapes. The binary prints a one-time hint if it detects tmux without passthrough enabled.
What if my terminal isn't Kitty / Ghostty / WezTerm?
Pass --protocol sixel for xterm / foot / Konsole — slower, lower fidelity, but readable. Anything else falls back to halfblocks, which is unreadable; treat that as confirmation the binary loads, then switch terminals.
macOS or Windows?
Not at launch. The hot path is Linux x86_64 and a vendored libpdfium.so. Porting needs the equivalent libpdfium.dylib / pdfium.dll wired through setup.sh and the runtime loader; help welcome on the issue tracker.