Skip to content

gpui-pdf

Page-virtualized PDF viewing for GPUI, built on the pure-Rust hayro rasterizer — no native libraries, no system-font dependency, so it builds and runs the same on macOS, Linux, and Windows.

It comes in two layers: low-level rasterization primitives you can build your own viewer on, and a ready-made PdfView component that handles loading, scrolling, rendering, and memory on its own.

  • Bounded memory. PdfView is page-virtualized: every page gets a correctly sized slot up front (so the scrollbar reflects the whole document), but only the pages near the viewport are rasterized. Pages scrolled away are freed — CPU pixel buffer and GPU atlas texture — so an 800-page document stays as light as a one-pager.
  • Zoom & navigation, no flicker. Built-in zoom (− / + / reset and ⌘=/⌘-/⌘0) and navigation (‹ / › with a click-to-edit page counter you can type a number into, plus PageUp / PageDown / Home / End, and a floating scroll-to-top button that appears once you’ve scrolled down). On a zoom or quality change the page never blanks — the current bitmap stays on screen (rescaled) until the crisp re-render lands. The nearest pages render first.
  • DPI-aware, host-settable quality. Pages rasterize at the display’s pixel ratio × zoom × a quality multiplier the host supplies (read reactively, like the theme), so a settings slider can trade sharpness for speed on slower machines — crisp on Retina by default.
  • Off-thread rendering. The file is read, parsed once, and measured on a background thread; pages rasterize on the background executor and paint as they land. The UI never blocks.
  • Password-protected PDFs. An encrypted file doesn’t fail to load — PdfView enters a locked state and emits an event so the host can render its own password prompt; unlock(password) retries (RC4 / AES-128 / AES-256, via hayro’s standard security handler). See Password-protected PDFs.
  • Outline & links. A table-of-contents side panel from the document’s outline, and clickable link annotations (internal → jump to page, external → open URL), both also exposed as plain functions (outline, page_links) for custom UIs.
  • Self-contained. PdfView is a gpui entity that owns its document, scroll position, zoom, render/evict loop, and styling. Drop the Entity<PdfView> into your element tree — no per-frame plumbing from the host.
  • Theme-reactive. Colors come from a closure read at paint time, so the viewer follows live theme changes (and can differ per window) with no push from the host.
  • Pure primitives. [parse], [page_dims], [render_page], and the [keep_window] virtualization math are plain functions (no entity required) for custom viewers.
use std::rc::Rc;
use std::path::PathBuf;
use gpui_pdf::{PdfView, PdfStyle};
// Create the viewer (kicks off the off-thread load):
let view = cx.new(|cx| {
PdfView::new(
path, // PathBuf to a local .pdf
Rc::new(|| PdfStyle { // map your theme onto the chrome
bg: my_theme::bg(),
border: my_theme::border(),
placeholder_bg: my_theme::muted_bg(),
placeholder_fg: my_theme::muted_fg(),
header_fg: my_theme::text(),
header_muted: my_theme::muted_fg(),
}),
Rc::new(|| 1.0), // render-quality multiplier (1.0 = native DPI)
cx,
)
});
// Render it like any child view:
div().child(view.clone())
// Free its GPU textures before dropping it (e.g. when its tab closes):
view.update(cx, |v, cx| v.release(window, cx));

A self-contained, page-virtualized viewer entity (impl Render).

MethodSignaturePurpose
newfn new(path: PathBuf, style: PdfStyleFn, quality: PdfQualityFn, cx: &mut Context<Self>) -> SelfCreate a viewer and start the off-thread read + parse + measure. style and quality are read at paint time (see below). Call inside cx.new(|cx| …).
releasefn release(&mut self, window: &mut Window, cx: &mut Context<Self>)Free every rasterized page (CPU buffer + GPU atlas texture). Call before dropping the view — gpui only frees a RenderImage’s atlas texture via drop_image, never on plain drop.
detach_texturesfn detach_textures(&mut self, window: &mut Window, cx: &mut Context<Self>)Free the GPU textures but keep the rendered page bitmaps — for hosts moving the view to a different window (e.g. a tab drag). The kept bitmaps re-upload wherever it next paints, so pages appear there immediately, with scroll, zoom, and unlocked state intact.
set_zoom / zoom_in / zoom_out / reset_zoomfn …(&mut self, cx: &mut Context<Self>) (set_zoom also takes zoom: f32)Change zoom (clamped 0.5–3.0), keeping the current page in view; the visible pages re-rasterize crisp at the new scale, with no blank.
go_to_page / next_page / prev_pagefn …(&mut self, cx: &mut Context<Self>) (go_to_page also takes index: usize)Scroll so the target page sits at the top of the viewport.
toggle_toc / has_outlinefn toggle_toc(&mut self, cx: &mut Context<Self>) · fn has_outline(&self) -> boolToggle the table-of-contents (outline) side panel; has_outline reports whether the document has one (so a host can hide the control).

The viewer renders a header with these controls — including a click-to-edit page counter (type a number, Enter to jump) and a table-of-contents toggle (≡) that opens a side panel of the document’s outline (when it has one; clicking an entry jumps to its page). It also overlays the document’s link annotations on each page — internal links jump to the target page, external links open the URL. Every control also has a keyboard shortcut (shown in its tooltip), all handled when the viewer is focused (it focuses on click): PageUp / PageDown / Home / End to navigate, ⌘= / ⌘- / ⌘0 to zoom, ⌘⌥G to jump to a page, and (with the relevant feature) ⌘⇧H to toggle highlight mode, ⌘F to find with ⌘G / ⌘⇧G stepping matches. Pages rasterize at the display’s pixel ratio × zoom × the quality multiplier.

Quality is host-set: there’s no set_quality method because the viewer reads the PdfQualityFn each paint, so changing the host’s value re-renders every open viewer (in every window) automatically.

Each PdfView owns its own scroll handle, so multiple open at once scroll independently.

pub struct PdfStyle {
pub bg: Hsla, // viewer background
pub border: Hsla, // page-slot border + header divider
pub placeholder_bg: Hsla, // unrendered page slot
pub placeholder_fg: Hsla, // "Page N" / "Loading…" text
pub header_fg: Hsla, // header filename
pub header_muted: Hsla, // header "· N pages"
}
pub type PdfStyleFn = Rc<dyn Fn() -> PdfStyle>;
pub type PdfQualityFn = Rc<dyn Fn() -> f32>; // render-quality multiplier source

PdfStyle::default() is a neutral dark palette. The viewer reads its colors — and its quality multiplier — through these closures at paint time (not stored), so returning fresh values each call lets it follow live theme / settings changes (in every window) with no push from the host. quality is 1.0 = native DPI; lower is faster and softer, higher supersamples (clamped internally).

pub type Document; // a parsed PDF (hayro)
pub enum LoadError { Locked, Other(String) } // Locked = encrypted, needs a password
pub fn parse(bytes: Arc<Vec<u8>>) -> Result<Arc<Document>, LoadError>;
pub fn parse_with_password(bytes: Arc<Vec<u8>>, password: &str)
-> Result<Arc<Document>, LoadError>; // Err(Locked) on a wrong/missing password
pub fn page_dims(doc: &Document) -> Vec<(f32, f32)>; // (w, h) points per page
pub fn render_page(doc: &Document, idx: usize, scale: f32)
-> Result<Arc<gpui::RenderImage>, String>; // BGRA over white
pub fn is_pdf(src: &str) -> bool; // extension check
pub const PAGE_WIDTH: f32; // base column width at zoom 1
pub fn keep_window(dims: &[(f32, f32)], page_width: f32, scroll_y: f32, viewport_h: f32)
-> (usize, usize); // inclusive visible range

Parse once, then rasterize pages on demand — hayro::Pdf is Send + Sync and caches pages internally, so share it via Arc across background tasks.

The document’s outline (table of contents) and per-page link annotations are exposed directly (always available, no feature gate) — the bundled PdfView uses these for its TOC panel + clickable links, but a host can drive its own navigation from them:

pub struct OutlineItem { pub title: String, pub level: usize, pub page: Option<usize> }
// `level` = nesting depth; `page` is None for an unresolved (named) destination.
pub enum LinkTarget { Page(usize), Uri(String) } // 0-based page index, or an external URL
pub struct PdfLink { pub x: f32, pub y: f32, pub w: f32, pub h: f32, pub target: LinkTarget }
// rect in normalized (0..1) page coordinates, top-left origin
pub fn outline(doc: &Document) -> Vec<OutlineItem>; // flattened depth-first; empty if no /Outlines
pub fn page_links(doc: &Document) -> Vec<Vec<PdfLink>>; // per page; a rotated page returns empty

Encrypted PDFs are unlocked by the host, so the prompt matches your app’s UI rather than a baked-in dialog. PdfView::new loads as usual; if the file is encrypted it doesn’t error — it enters a locked state and emits PdfEvent::LockChanged. While is_locked(), render your own password prompt; call unlock(password) to retry. On success the viewer renders; on a wrong password unlock_failed() flips true and it stays locked. The file bytes are kept, so retries don’t re-read the disk. Subscribe to PdfEvent to re-render the prompt ↔ viewer on each transition.

pub enum PdfEvent { LockChanged } // impl EventEmitter<PdfEvent> for PdfView
impl PdfView {
pub fn is_locked(&self) -> bool; // encrypted + not yet unlocked
pub fn unlock_failed(&self) -> bool; // the last unlock used a wrong password
pub fn unlock(&mut self, password: String, cx: &mut Context<Self>); // retry (async)
}

Building a custom viewer instead of using PdfView? Use the primitive directly:

match gpui_pdf::parse_with_password(bytes, password) {
Ok(doc) => { /* render */ }
Err(gpui_pdf::LoadError::Locked) => { /* prompt for a password and retry */ }
Err(gpui_pdf::LoadError::Other(e)) => { /* malformed / unsupported encryption */ }
}

Decryption is hayro’s, via the PDF standard security handler (the password-based scheme — supply the password that opens the document):

AlgorithmPDF /VNotes
RC4, 40-bit1legacy
RC4, 40–128-bit2key length from /Length
AES-1284AESV2 crypt filter (RC4 via a V2 filter also works)
AES-2565 / 6AESV3 crypt filter; PDF 2.0 (revision 6)

Anything else surfaces as LoadError::Other — a logged error and a blank viewer, not the prompt: public-key / certificate security handlers (/Filter/Standard) and any non-standard crypt filter.

Opt-in text-anchored highlights, with no heavyweight dependency — a custom hayro Device extracts the page’s text + glyph rectangles (only kurbo geometry, already in hayro’s tree; no oxidize-pdf). Storage stays the host’s: hand the viewer the highlights to draw (e.g. derived from notes that quote the PDF) and it locates each quote and boxes it.

// Text layer (also usable standalone, e.g. for search):
pub fn extract_page_text(doc: &Document, page: usize) -> Option<PageText>;
impl PageText {
pub fn is_empty(&self) -> bool; // true for a scan with no text layer
pub fn text(&self) -> String; // readable reconstruction
pub fn locate(&self, needle: &str, occurrence: usize)
-> Vec<NormRect>; // one rect per line spanned
}
pub struct NormRect { pub x: f32, pub y: f32, pub w: f32, pub h: f32 } // 0..1 of the page
// Drawing highlights on the viewer:
pub struct Highlight { pub id: u64, pub page: usize, pub quote: String,
pub occurrence: usize, pub color: Hsla }
impl PdfView {
pub fn set_highlights(&mut self, highlights: Vec<Highlight>, cx: &mut Context<Self>);
pub fn set_on_highlight(&mut self, handler: HighlightClickFn); // click → id
// A color picker: the ✎ toggle pops down a palette of (label, fill) swatches; the
// active color tints new highlights and its label is echoed back on create.
pub fn set_highlight_palette(&mut self, palette: Vec<(SharedString, Hsla)>,
cx: &mut Context<Self>);
// Interactive creation: ✎ turns on "highlight mode", where dragging over text
// resolves a selection and fires the create handler.
pub fn set_on_create_highlight(&mut self, handler: CreateHighlightFn);
// CreateHighlightFn = Rc<dyn Fn(page: usize, quote: String, occurrence: usize,
// color_label: SharedString, &mut Window, &mut App)>
// (the color_label echoes the active palette swatch's label)
pub fn toggle_select_mode(&mut self, cx: &mut Context<Self>);
// Jump from a note: scroll a page in (to its first highlight) and flash them.
pub fn reveal_highlight(&mut self, page: usize, cx: &mut Context<Self>);
}
impl PageText { // drag → selection, also usable directly
pub fn select(&self, from: NormPoint, to: NormPoint) -> Option<Selection>;
}

locate matches case- and whitespace-insensitively (so a quote survives PDF spacing quirks) and returns one normalized rect per line it spans. The viewer extracts a page’s text lazily — off-thread, cached — when a highlighted page scrolls into view. Because coordinates are normalized, highlights track zoom and DPI for free. The host owns storage: on create it persists the quote and color label (however it likes) and feeds the highlights back via set_highlights. For the reverse direction, a note can link to file.pdf#pN and call [reveal_highlight] to scroll to and flash it.

Early, but solid for scroll-to-read viewing. Renders via the pure-Rust hayro crate. Password-protected PDFs (RC4 / AES-128 / AES-256) open behind a host-rendered prompt. Not yet published to crates.io.

Text extraction, highlight rendering, drag-to-select creation (with a color picker), and note→PDF reverse links (scroll to + flash a highlight) are all available behind markup (dep-free). A browser-style find-in-PDF bar (🔍 / ⌘F) sits on top of the same text layer behind the search feature (= ["markup"]). Roadmap: area highlights for pages with no text layer.

GPL-3.0-or-later.