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.

$git clone https://github.com/amanagr/termpdf-rs && cd termpdf-rs && ./setup.sh && cargo build --release
~/papers — termpdf paper.pdf

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.

Idle on Designing Data-Intensive Applications (623 pages)
Metrictermpdf-rsChromeRatio
Idle CPU%3.2%17.1%5.3×
Idle RSS71 MB558 MB7.9×
Sustained held-j for ~25 s on the same book
Metrictermpdf-rs + GhosttyFirefoxRatio
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'

Five keys to get started

KeyWhat it does
j / kNext / prev page
SpaceScroll one screen down (less-style)
oOpen the table-of-contents panel — / to fuzzy-filter
vVisual mode → viw/vis/vipy to highlight
fLink-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 / knext / prev page
Space / bone screen down / up (less-style)
Ctrl-d / Ctrl-uhalf-screen down / up
gg / Gfirst / last page
:n / N Gjump to page N
]] / [[next / prev outline section
m{a-z} / '{a-z}set / jump to mark (persisted per PDF)
Ctrl-o / Ctrl-ijumplist back / forward
+ / - / 0zoom in / out / reset
dtoggle color-aware dark mode
flink-follow hint mode

Highlight & quote (Visual — v)

h j k l · w b e · 0 ^ $move the caret like in vim
iw / is / ipselect inner word / sentence / paragraph
V / Ctrl-vlinewise / blockwise selection
ccycle highlight color
ysave highlight + copy plain text
Ycopy plain text only (no highlight)
gycopy as Markdown blockquote with citation
click + draghighlight with the mouse
x (Normal)delete last highlight on current page

Search, TOC, export

/<query> then n / Nsearch · next / prev match (indexed)
:nohlclear search results
oopen 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.