Merge remote-tracking branch 'kk376/feat/pdf-viewer' into pdf-viewer

# Conflicts:
#	README.md
This commit is contained in:
yenru0 committed 2026-09-22 19:48:31 +09:00
commit 617a8d3814
23 files changed
+4596 -70

No files matched your search

Generated
+77
View File
@@ -3665,6 +3665,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "console_log"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1"
dependencies = [
"log",
"web-sys",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -8761,9 +8771,11 @@ dependencies = [
"file_icons",
"fs",
"gpui",
"image",
"language",
"log",
"menu",
"pdf_viewer",
"project",
"settings",
"theme",
@@ -12895,6 +12907,46 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0008e816fcdaf229cdd540e9b6ca2dc4a10d65c31624abb546c6420a02846e61"
[[package]]
name = "pdf_viewer"
version = "0.1.0"
dependencies = [
"anyhow",
"image",
"log",
"parking_lot",
"pdfium-render",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "pdfium-render"
version = "0.8.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6553f6604a52b3203db7b4e9d51eb4dd193cf455af9e56d40cab6575b547b679"
dependencies = [
"bitflags 2.13.1",
"bytemuck",
"bytes 1.11.1",
"chrono",
"console_error_panic_hook",
"console_log",
"image",
"itertools 0.15.0",
"js-sys",
"libloading",
"log",
"maybe-owned",
"once_cell",
"utf16string",
"vecmath",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "peg"
version = "0.8.5"
@@ -13630,6 +13682,12 @@ dependencies = [
"futures-io",
]
[[package]]
name = "piston-float"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590"
[[package]]
name = "pkcs1"
version = "0.7.5"
@@ -14113,6 +14171,7 @@ dependencies = [
"parking_lot",
"path",
"paths",
"pdf_viewer",
"percent-encoding",
"postage",
"prettier",
@@ -19778,6 +19837,15 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf16string"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216"
dependencies = [
"byteorder",
]
[[package]]
name = "utf8-chars"
version = "3.0.6"
@@ -19932,6 +20000,15 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vecmath"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a"
dependencies = [
"piston-float",
]
[[package]]
name = "version-compare"
version = "0.2.0"
+3
View File
@@ -157,6 +157,7 @@ members = [
"crates/outline_panel",
"crates/path",
"crates/paths",
"crates/pdf_viewer",
"crates/picker",
"crates/picker_preview",
"crates/platform_title_bar",
@@ -425,6 +426,7 @@ outline = { path = "crates/outline" }
outline_panel = { path = "crates/outline_panel" }
path = { path = "crates/path" }
paths = { path = "crates/paths" }
pdf_viewer = { path = "crates/pdf_viewer" }
perf = { path = "tooling/perf" }
picker = { path = "crates/picker" }
picker_preview = { path = "crates/picker_preview" }
@@ -763,6 +765,7 @@ parse_int = "0.9"
partial-json-fixer = "0.5.3"
pathfinder_geometry = "0.5"
pciid-parser = "0.8.0"
pdfium-render = { version = "0.8", features = ["image"] }
percent-encoding = "2.3.2"
pet = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" }
pet-conda = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" }
+1
View File
@@ -1,5 +1,6 @@
> [!IMPORTANT]
> Remove this line to confirm you've reviewed this PR before submitting.
# Zed
[![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev)
+10
View File
@@ -1617,6 +1617,16 @@
"ctrl-1": "image_viewer::ZoomToActualSize",
"ctrl-k r": "editor::RevealInFileManager",
"ctrl-shift-0": "image_viewer::FitToView",
"right": "image_viewer::NextPage",
"pagedown": "image_viewer::NextPage",
"j": "image_viewer::NextPage",
"left": "image_viewer::PreviousPage",
"pageup": "image_viewer::PreviousPage",
"k": "image_viewer::PreviousPage",
"home": "image_viewer::FirstPage",
"end": "image_viewer::LastPage",
"ctrl-c": "image_viewer::CopyText",
"ctrl-shift-t": "image_viewer::ToggleTextPanel"
},
},
{
+10
View File
@@ -1669,6 +1669,16 @@
"cmd-1": "image_viewer::ZoomToActualSize",
"cmd-k r": "editor::RevealInFileManager",
"cmd-shift-0": "image_viewer::FitToView",
"right": "image_viewer::NextPage",
"pagedown": "image_viewer::NextPage",
"j": "image_viewer::NextPage",
"left": "image_viewer::PreviousPage",
"pageup": "image_viewer::PreviousPage",
"k": "image_viewer::PreviousPage",
"home": "image_viewer::FirstPage",
"end": "image_viewer::LastPage",
"cmd-c": "image_viewer::CopyText",
"cmd-shift-t": "image_viewer::ToggleTextPanel"
},
},
{
+10
View File
@@ -1592,6 +1592,16 @@
"ctrl-1": "image_viewer::ZoomToActualSize",
"ctrl-k r": "editor::RevealInFileManager",
"ctrl-shift-0": "image_viewer::FitToView",
"right": "image_viewer::NextPage",
"pagedown": "image_viewer::NextPage",
"j": "image_viewer::NextPage",
"left": "image_viewer::PreviousPage",
"pageup": "image_viewer::PreviousPage",
"k": "image_viewer::PreviousPage",
"home": "image_viewer::FirstPage",
"end": "image_viewer::LastPage",
"ctrl-c": "image_viewer::CopyText",
"ctrl-shift-t": "image_viewer::ToggleTextPanel"
},
},
{
+2
View File
@@ -21,9 +21,11 @@ db.workspace = true
editor.workspace = true
file_icons.workspace = true
gpui.workspace = true
image.workspace = true
language.workspace = true
log.workspace = true
menu.workspace = true
pdf_viewer.workspace = true
project.workspace = true
settings.workspace = true
theme_settings.workspace = true
+36 -18
View File
@@ -9,6 +9,7 @@ use crate::{ImageFileSizeUnit, ImageView, ImageViewerSettings};
pub struct ImageInfo {
metadata: Option<ImageMetadata>,
pdf_info: Option<(usize, usize)>,
_observe_active_image: Option<Subscription>,
observe_image_item: Option<Subscription>,
}
@@ -17,6 +18,7 @@ impl ImageInfo {
pub fn new(_workspace: &Workspace) -> Self {
Self {
metadata: None,
pdf_info: None,
_observe_active_image: None,
observe_image_item: None,
}
@@ -24,16 +26,25 @@ impl ImageInfo {
fn update_metadata(&mut self, image_view: &Entity<ImageView>, cx: &mut Context<Self>) {
let image_item = image_view.read(cx).image_item.clone();
let current_metadata = image_item.read(cx).image_metadata;
if current_metadata.is_some() {
self.metadata = current_metadata;
cx.notify();
let item_ref = image_item.read(cx);
self.metadata = item_ref.image_metadata;
if item_ref.is_pdf() {
self.pdf_info = Some((item_ref.current_page(), item_ref.total_pages()));
} else {
self.observe_image_item = Some(cx.observe(&image_item, |this, item, cx| {
this.metadata = item.read(cx).image_metadata;
cx.notify();
}));
self.pdf_info = None;
}
self.observe_image_item = Some(cx.observe(&image_item, |this, item, cx| {
let item_ref = item.read(cx);
this.metadata = item_ref.image_metadata;
if item_ref.is_pdf() {
this.pdf_info = Some((item_ref.current_page(), item_ref.total_pages()));
} else {
this.pdf_info = None;
}
cx.notify();
}));
cx.notify();
}
}
@@ -51,6 +62,9 @@ impl Render for ImageInfo {
};
let mut components = Vec::new();
if let Some((current_page, total_pages)) = self.pdf_info {
components.push(format!("Page {} of {}", current_page + 1, total_pages));
}
components.push(format!("{}x{}", metadata.width, metadata.height));
components.push(format_image_size(metadata.file_size, settings.unit));
@@ -63,16 +77,20 @@ impl Render for ImageInfo {
}
components.push(
match metadata.format {
ImageFormat::Png => "PNG",
ImageFormat::Jpeg => "JPEG",
ImageFormat::Gif => "GIF",
ImageFormat::WebP => "WebP",
ImageFormat::Tiff => "TIFF",
ImageFormat::Bmp => "BMP",
ImageFormat::Ico => "ICO",
ImageFormat::Avif => "Avif",
_ => "Unknown",
if self.pdf_info.is_some() {
"PDF"
} else {
match metadata.format {
ImageFormat::Png => "PNG",
ImageFormat::Jpeg => "JPEG",
ImageFormat::Gif => "GIF",
ImageFormat::WebP => "WebP",
ImageFormat::Tiff => "TIFF",
ImageFormat::Bmp => "BMP",
ImageFormat::Ico => "ICO",
ImageFormat::Avif => "Avif",
_ => "Unknown",
}
}
.to_string(),
);
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "pdf_viewer"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/pdf_viewer.rs"
doctest = false
[dependencies]
anyhow.workspace = true
image.workspace = true
log.workspace = true
parking_lot.workspace = true
pdfium-render.workspace = true
serde.workspace = true
[dev-dependencies]
serde_json.workspace = true
tempfile.workspace = true
+95
View File
@@ -0,0 +1,95 @@
# `pdf_viewer`: Native PDF Viewer for Zed
A native, high performance PDF viewing engine and GPUI workspace item engineered for Zed.
---
## 0. Architectural Identity & Delivery Model
> **Important**: This is a **native workspace crate**, not an installable WASM extension.
> As of current Zed versions, Zed's WebAssembly extension API (`zed_extension_api`) contains no custom UI or file preview surfaces. `pdf_viewer` is designed as a native crate registered within the Zed workspace (`crates/pdf_viewer`).
---
## 1. Core Architectural Pillars
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PDF_VIEWER CORE PIPELINE │
└──────────────────────────────────────────────────────────────────────────────────┘
│
[PDF Document on Disk / Memory Buffer]
│
▼
[Thread Safe Document Handle (`document.rs`)]
│
▼
[Async Background Worker Pool (`gpui::Task` / Threads)]
│
(Zero UI Thread Blocking Rasterization)
│
▼
[Memory Budgeted LRU Cache (`cache.rs`)]
│
(Visible Viewport + 1 Page Margin)
│
▼
[Luminosity Tone Mapping Engine (`ui.rs`)]
│
(Inverts White/Black; Preserves Images)
│
▼
[Native GPUI Canvas / Bitmap Painter (`view.rs`)]
```
1. **Zero UI Thread Blocking**: PDF parsing and rasterization never execute on GPUI's main render loop. Work is dispatched to background tasks that stream RGBA framebuffers into memory.
2. **Viewport Virtualization & LRU Caching**: Pages are rasterized on demand for the visible viewport plus a 1 page prefetch margin. Stale or passed by in flight render requests are cancelled during fast scrolls to prevent thread pool starvation.
3. **Luminosity Threshold Dark Mode**: Unlike simple blanket RGB inversion (which turns photos and charts into negative ghosts), `pdf_viewer` implements selective tone mapping, remapping near white canvas backgrounds to editor background tones and near black text to theme light text, while preserving saturated color images.
4. **Scroll & Zoom Preserving Live Reload**: Watches the underlying PDF on disk (via file system notifications) and reloads seamlessly without resetting the user's scroll percentage or zoom level.
---
## 2. Scope Boundaries
- **Continuous Scroll & Text Selection**: Supports fluid high frame rate document browsing and visual text selection.
- **Excluded from initial scope**:
- Interactive PDF form field editing.
- *Rationale*: Delivering an ultra fast, stutter free viewing experience with zero UI locks takes precedence.
---
## 3. Security Posture & Threat Model
- **In Process Parsing**: PDF rasterization is performed via Google's native C++ [Pdfium](https://pdfium.googlesource.com/pdfium/) engine linked in process with the editor.
- **Accepted Risk**: Because Pdfium runs in process without sandboxing (for example, without separate process IPC or WebAssembly memory isolation), malformed or malicious PDFs could theoretically trigger memory vulnerabilities in the underlying C++ library.
- **Mitigation & Future Hardening**: Memory budgets are strictly capped, document handles are isolated behind thread boundaries, and a future sandboxed worker process model is planned for untrusted file browsing.
---
## 4. Repository Layout
```
crates/pdf_viewer/
├── Cargo.toml # Native crate manifest
├── README.md # Architecture & operational guide
├── tests/
│ └── integration_tests.rs # End to end integration tests
└── src/
├── pdf_viewer.rs # Crate entrypoint & exports
├── cache.rs # Memory budgeted generic PageLruCache
├── document.rs # Thread safe PdfDocument handle
├── pdfium.rs # Pdfium engine binding & coordinate normalization
├── rasterizer.rs # Async background rendering pipeline
├── settings.rs # PDF viewer configuration options
├── ui.rs # Toolbar state & action models
├── view.rs # GPUI Render & Workspace Item implementation
└── watcher.rs # Live reload file watcher & scroll state
```
---
## 5. Local Development & Testing
Run unit and integration tests across the caching and rasterization engines:
cargo test -p pdf_viewer
+348
View File
@@ -0,0 +1,348 @@
//! Memory-budgeted LRU cache for rasterized PDF page bitmaps.
//!
//! Stores rendered RGBA framebuffers indexed by `(page_index, zoom_bucket, dark_mode)`.
//! Automatically evicts oldest unused pages when memory consumption exceeds the configured budget.
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
/// Default maximum memory budget: 256 Megabytes.
pub const DEFAULT_MEMORY_BUDGET_BYTES: usize = 256 * 1024 * 1024;
/// Cache lookup key capturing page index, discrete zoom bucket, and dark-mode setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub page_index: usize,
pub zoom_bucket: u32,
pub dark_mode: bool,
}
impl CacheKey {
/// Creates a new cache key with zoom discretized to 2 decimal places (e.g. 1.25 -> 125).
pub fn new(page_index: usize, zoom_factor: f32, dark_mode: bool) -> Self {
let zoom_bucket = (zoom_factor * 100.0).round().max(1.0) as u32;
Self {
page_index,
zoom_bucket,
dark_mode,
}
}
}
/// A fully rasterized PDF page holding raw RGBA pixel data.
#[derive(Debug, Clone)]
pub struct RenderedPage {
pub page_index: usize,
pub width: u32,
pub height: u32,
pub zoom_factor: f32,
pub dark_mode: bool,
pub rgba_buffer: Arc<Vec<u8>>,
}
impl RenderedPage {
/// Constructs a new RenderedPage.
pub fn new(
page_index: usize,
width: u32,
height: u32,
zoom_factor: f32,
dark_mode: bool,
rgba_buffer: Vec<u8>,
) -> Self {
Self {
page_index,
width,
height,
zoom_factor,
dark_mode,
rgba_buffer: Arc::new(rgba_buffer),
}
}
/// Returns the exact memory footprint of this bitmap in bytes (width * height * 4).
#[inline]
pub fn byte_size(&self) -> usize {
(self.width as usize) * (self.height as usize) * 4
}
}
/// Trait for items stored in an LRU page cache that report their memory footprint.
pub trait CacheablePage: Clone {
fn byte_size(&self) -> usize;
}
impl CacheablePage for RenderedPage {
fn byte_size(&self) -> usize {
(self.width as usize) * (self.height as usize) * 4
}
}
/// A Least-Recently-Used (LRU) cache bounded by byte size and item limits.
#[derive(Debug)]
pub struct PageLruCache<T = RenderedPage> {
max_memory_bytes: usize,
current_memory_bytes: usize,
max_pages: Option<usize>,
entries: HashMap<CacheKey, T>,
lru_order: VecDeque<CacheKey>,
}
impl<T: CacheablePage> PageLruCache<T> {
/// Creates a new cache with the specified memory budget in bytes.
pub fn new(max_memory_bytes: usize) -> Self {
Self {
max_memory_bytes: max_memory_bytes.max(1024 * 1024), // Minimum 1MB
current_memory_bytes: 0,
max_pages: None,
entries: HashMap::new(),
lru_order: VecDeque::new(),
}
}
/// Configures an optional hard ceiling on the maximum number of cached pages.
pub fn with_max_pages(mut self, max_pages: usize) -> Self {
self.max_pages = Some(max_pages);
self
}
/// Retrieves a cached page, refreshing its LRU position.
pub fn get(&mut self, key: &CacheKey) -> Option<T> {
if self.entries.contains_key(key) {
// Move key to back (most recently used)
if let Some(pos) = self.lru_order.iter().position(|k| k == key) {
self.lru_order.remove(pos);
self.lru_order.push_back(*key);
}
self.entries.get(key).cloned()
} else {
None
}
}
/// Inserts a newly rasterized page into the cache, evicting older pages if needed.
pub fn insert(&mut self, key: CacheKey, page: T) {
let page_size = page.byte_size();
// If replacing existing entry, remove old size first
if let Some(old_page) = self.entries.remove(&key) {
self.current_memory_bytes = self
.current_memory_bytes
.saturating_sub(old_page.byte_size());
if let Some(pos) = self.lru_order.iter().position(|k| k == &key) {
self.lru_order.remove(pos);
}
}
// Evict until new page fits within budget
self.evict_for_space(page_size);
// If page is larger than max budget itself, we do not cache it
if page_size > self.max_memory_bytes {
log::warn!(
"Rendered page size ({page_size} bytes) exceeds total cache budget ({max} bytes); skipping cache insertion",
max = self.max_memory_bytes
);
return;
}
self.current_memory_bytes += page_size;
self.entries.insert(key, page);
self.lru_order.push_back(key);
}
/// Evicts oldest entries until at least `needed_bytes` is available.
fn evict_for_space(&mut self, needed_bytes: usize) {
// Enforce page count limit if configured
if let Some(max_pages) = self.max_pages {
while self.entries.len() >= max_pages {
if !self.evict_oldest() {
break;
}
}
}
// Enforce memory budget
while self.current_memory_bytes + needed_bytes > self.max_memory_bytes {
if !self.evict_oldest() {
break;
}
}
}
/// Evicts the single least recently used item. Returns false if cache was empty.
fn evict_oldest(&mut self) -> bool {
if let Some(oldest_key) = self.lru_order.pop_front() {
if let Some(evicted_page) = self.entries.remove(&oldest_key) {
self.current_memory_bytes = self
.current_memory_bytes
.saturating_sub(evicted_page.byte_size());
log::debug!(
"Evicted page {page_idx} (zoom: {zoom}) to free {bytes} bytes (current memory: {cur} / {max})",
page_idx = oldest_key.page_index,
zoom = oldest_key.zoom_bucket,
bytes = evicted_page.byte_size(),
cur = self.current_memory_bytes,
max = self.max_memory_bytes
);
return true;
}
}
false
}
/// Clears all entries from the cache and resets memory tracking.
pub fn clear(&mut self) {
self.entries.clear();
self.lru_order.clear();
self.current_memory_bytes = 0;
}
/// Number of pages currently cached.
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Returns true if the cache contains no pages.
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Current memory usage in bytes.
#[inline]
pub fn memory_usage(&self) -> usize {
self.current_memory_bytes
}
/// Total memory in bytes currently consumed by cached pages (alias for `memory_usage`).
#[inline]
pub fn current_memory_bytes(&self) -> usize {
self.current_memory_bytes
}
/// Maximum memory budget in bytes.
#[inline]
pub fn max_memory(&self) -> usize {
self.max_memory_bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_key_discretization() {
let key1 = CacheKey::new(0, 1.004, false);
let key2 = CacheKey::new(0, 1.001, false);
let key3 = CacheKey::new(0, 1.250, false);
assert_eq!(key1, key2);
assert_ne!(key1, key3);
assert_eq!(key1.zoom_bucket, 100);
assert_eq!(key3.zoom_bucket, 125);
}
#[test]
fn test_lru_eviction_on_memory_budget() {
// 1 MB cache budget
let mut cache = PageLruCache::new(1024 * 1024);
// Create 3 mock pages of 400 KB each (100x1000 RGBA = 400,000 bytes)
let page_size_bytes = 400_000;
let buf = vec![0u8; page_size_bytes];
let page0 = RenderedPage::new(0, 100, 1000, 1.0, false, buf.clone());
let page1 = RenderedPage::new(1, 100, 1000, 1.0, false, buf.clone());
let page2 = RenderedPage::new(2, 100, 1000, 1.0, false, buf);
let key0 = CacheKey::new(0, 1.0, false);
let key1 = CacheKey::new(1, 1.0, false);
let key2 = CacheKey::new(2, 1.0, false);
// Insert page 0 (400 KB) -> total: 400 KB
cache.insert(key0, page0);
assert_eq!(cache.len(), 1);
assert_eq!(cache.memory_usage(), 400_000);
// Insert page 1 (400 KB) -> total: 800 KB
cache.insert(key1, page1);
assert_eq!(cache.len(), 2);
assert_eq!(cache.memory_usage(), 800_000);
// Insert page 2 (400 KB) -> 800 + 400 = 1200 KB > 1024 KB
// Page 0 should be evicted!
cache.insert(key2, page2);
assert_eq!(cache.len(), 2);
assert!(
cache.get(&key0).is_none(),
"Page 0 should have been evicted"
);
assert!(cache.get(&key1).is_some(), "Page 1 should still exist");
assert!(cache.get(&key2).is_some(), "Page 2 should still exist");
}
#[test]
fn test_lru_access_refresh() {
let mut cache = PageLruCache::new(1024 * 1024);
let buf = vec![0u8; 400_000];
let key0 = CacheKey::new(0, 1.0, false);
let key1 = CacheKey::new(1, 1.0, false);
let key2 = CacheKey::new(2, 1.0, false);
cache.insert(
key0,
RenderedPage::new(0, 100, 1000, 1.0, false, buf.clone()),
);
cache.insert(
key1,
RenderedPage::new(1, 100, 1000, 1.0, false, buf.clone()),
);
// Access page 0, making page 1 the oldest
let _ = cache.get(&key0);
// Insert page 2 -> page 1 should be evicted instead of page 0!
cache.insert(key2, RenderedPage::new(2, 100, 1000, 1.0, false, buf));
assert!(
cache.get(&key0).is_some(),
"Page 0 was accessed and should survive"
);
assert!(
cache.get(&key1).is_none(),
"Page 1 was oldest and should be evicted"
);
assert!(cache.get(&key2).is_some(), "Page 2 is newly inserted");
}
#[test]
fn test_generic_lru_cache_custom_page() {
#[derive(Clone, Debug, PartialEq, Eq)]
struct MockCustomPage {
id: usize,
size: usize,
}
impl CacheablePage for MockCustomPage {
fn byte_size(&self) -> usize {
self.size
}
}
let mut cache: PageLruCache<MockCustomPage> = PageLruCache::new(1024 * 1024);
let key0 = CacheKey::new(0, 1.0, false);
let key1 = CacheKey::new(1, 1.0, false);
cache.insert(key0, MockCustomPage { id: 100, size: 600_000 });
cache.insert(key1, MockCustomPage { id: 101, size: 600_000 });
// Total 1.2 MB > 1 MB, key0 must be evicted
assert!(cache.get(&key0).is_none());
assert_eq!(cache.get(&key1), Some(MockCustomPage { id: 101, size: 600_000 }));
assert_eq!(cache.len(), 1);
assert_eq!(cache.memory_usage(), 600_000);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Thread-safe PDF document handle and metadata index.
//!
//! Exposes page count, dimensions, and aspect ratios without requiring
//! active rendering locks on the UI thread.
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Dimension metrics for a single PDF page in standard points (1/72 inch).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageDimensions {
pub width: f32,
pub height: f32,
}
impl PageDimensions {
/// Constructs dimensions from width and height.
#[inline]
pub fn new(width: f32, height: f32) -> Self {
Self { width, height }
}
/// Aspect ratio (width / height). Guaranteed non-zero.
#[inline]
pub fn aspect_ratio(&self) -> f32 {
if self.height > 0.0 {
self.width / self.height
} else {
1.0
}
}
/// Calculates scaled dimensions in pixels for a given scale and target DPI.
#[inline]
pub fn to_pixel_size(&self, zoom_factor: f32, dpi: f32) -> (u32, u32) {
let scale = (dpi / 72.0) * zoom_factor;
let w = (self.width * scale).round().max(1.0) as u32;
let h = (self.height * scale).round().max(1.0) as u32;
(w, h)
}
}
/// Metadata and page geometry cache for an opened PDF document.
#[derive(Debug, Clone)]
pub struct PdfDocument {
path: Option<PathBuf>,
pages: Arc<Vec<PageDimensions>>,
title: Option<String>,
}
impl PdfDocument {
/// Constructs a PdfDocument with explicit page dimensions.
pub fn new(path: Option<PathBuf>, pages: Vec<PageDimensions>, title: Option<String>) -> Self {
Self {
path,
pages: Arc::new(pages),
title,
}
}
/// Total number of pages in the document.
#[inline]
pub fn total_pages(&self) -> usize {
self.pages.len()
}
/// True if the document has 0 pages.
#[inline]
pub fn is_empty(&self) -> bool {
self.pages.is_empty()
}
/// Dimensions of the page at `page_index` (0-indexed).
#[inline]
pub fn page_size(&self, page_index: usize) -> Option<PageDimensions> {
self.pages.get(page_index).copied()
}
/// Aspect ratio of the page at `page_index` (0-indexed).
#[inline]
pub fn aspect_ratio(&self, page_index: usize) -> Option<f32> {
self.pages.get(page_index).map(|d| d.aspect_ratio())
}
/// Absolute file system path if loaded from disk.
#[inline]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// Slice of all page dimensions in the document.
#[inline]
pub fn pages(&self) -> &[PageDimensions] {
&self.pages
}
/// Computes overall width and height in pixels for the entire stitched document.
pub fn total_pixel_size(&self, zoom_factor: f32, dpi: f32, page_gap: u32) -> (u32, u32) {
if self.pages.is_empty() {
return (0, 0);
}
let mut max_w = 0u32;
let mut total_h = 0u32;
for (i, page) in self.pages.iter().enumerate() {
let (w, h) = page.to_pixel_size(zoom_factor, dpi);
max_w = max_w.max(w);
total_h += h;
if i + 1 < self.pages.len() {
total_h += page_gap;
}
}
(max_w, total_h)
}
/// Document title if available in metadata, or falls back to filename.
pub fn display_title(&self) -> String {
if let Some(ref title) = self.title {
if !title.trim().is_empty() {
return title.clone();
}
}
if let Some(ref path) = self.path {
if let Some(file_name) = path.file_name() {
return file_name.to_string_lossy().to_string();
}
}
"Untitled Document.pdf".to_string()
}
/// Inspects raw PDF bytes to extract basic page count and geometry.
/// Fast fallback scanner when native Pdfium context is running in a background task.
pub fn from_bytes(bytes: &[u8], path: Option<PathBuf>) -> Result<Self> {
// Validate PDF magic bytes (%PDF-)
if bytes.len() < 5 || &bytes[0..5] != b"%PDF-" {
anyhow::bail!("Invalid PDF header magic bytes");
}
// Default standard US Letter (612x792 pt) if full metadata parser not yet invoked
let default_page = PageDimensions::new(612.0, 792.0);
let pages = vec![default_page];
Ok(Self::new(path, pages, None))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_page_dimensions_and_scaling() {
let dim = PageDimensions::new(600.0, 800.0);
assert_eq!(dim.aspect_ratio(), 0.75);
// At 72 DPI and 1.0x zoom -> exactly 600x800 px
let (w, h) = dim.to_pixel_size(1.0, 72.0);
assert_eq!((w, h), (600, 800));
// At 144 DPI (Retina 2x) and 1.5x zoom -> 600 * 2 * 1.5 = 1800
let (w2, h2) = dim.to_pixel_size(1.5, 144.0);
assert_eq!((w2, h2), (1800, 2400));
}
#[test]
fn test_document_metadata_access() {
let pages = vec![
PageDimensions::new(595.0, 842.0), // A4
PageDimensions::new(612.0, 792.0), // US Letter
];
let doc = PdfDocument::new(
Some(PathBuf::from("/tmp/sample.pdf")),
pages,
Some("Sample".into()),
);
assert_eq!(doc.total_pages(), 2);
assert_eq!(doc.page_size(0), Some(PageDimensions::new(595.0, 842.0)));
assert_eq!(doc.page_size(1), Some(PageDimensions::new(612.0, 792.0)));
assert_eq!(doc.page_size(2), None);
assert_eq!(doc.display_title(), "Sample");
// Test total_pixel_size: at 72 DPI, 1.0x zoom, 20px gap
// max width = max(595, 612) = 612
// total height = 842 + 792 + 20 = 1654
let (total_w, total_h) = doc.total_pixel_size(1.0, 72.0, 20);
assert_eq!((total_w, total_h), (612, 1654));
}
#[test]
fn test_pdf_magic_bytes_check() {
let valid_pdf = b"%PDF-1.7\nSample content";
assert!(PdfDocument::from_bytes(valid_pdf, None).is_ok());
let invalid_pdf = b"NOT A PDF";
assert!(PdfDocument::from_bytes(invalid_pdf, None).is_err());
}
}
+31
View File
@@ -0,0 +1,31 @@
//! # `pdf_viewer`
//!
//! High performance native PDF viewing engine and GPUI workspace item for Zed Editor.
//!
//! Features:
//! - High performance viewport rendering via bounded LRU memory caching
//! - Smart luminosity threshold dark mode tone mapping preserving saturated colors
//! - Continuous multi page scroll, single page, and two page spread layouts
//! - Precision focal point zoom (10% to 2000%) with mouse and gesture support
//! - Debounced disk change watcher preserving exact scroll percentages and zoom levels
//! - Native Pdfium C++ engine bindings with robust fallback support
pub mod cache;
pub mod document;
pub mod pdfium;
pub mod rasterizer;
pub mod settings;
pub mod ui;
pub mod view;
pub mod watcher;
pub use cache::{CacheKey, CacheablePage, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES};
pub use document::{PageDimensions, PdfDocument};
pub use pdfium::{
PdfDocumentDetails, PdfLinkAnnotation, PdfPageDetails, PdfTextSegment, PdfiumEngine,
};
pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions};
pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings};
pub use ui::{PdfToolbarAction, PdfToolbarState};
pub use view::{PdfView, PdfViewEvent, MAX_ZOOM, MIN_ZOOM, PAGE_SPACING_PX, ZOOM_STEP};
pub use watcher::{PdfReloadDebouncer, ViewerStateSnapshot};
+978
View File
@@ -0,0 +1,978 @@
//! High-level Pdfium engine abstraction and dynamic library loader.
//!
//! Provides thread-safe document rendering via Google Pdfium C++ engine,
//! with automated fallback and library path discovery.
use anyhow::{Context as _, Result};
use parking_lot::Mutex;
use pdfium_render::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::cache::RenderedPage;
use crate::document::{PageDimensions, PdfDocument};
use crate::rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions};
/// Internal wrapper granting thread-safe synchronization to Pdfium handle.
struct SerializedPdfium(Option<Pdfium>);
// SAFETY: All calls to the internal Pdfium C++ FFI handle are strictly guarded
// by a Mutual Exclusion (Mutex) lock, guaranteeing serialized single-thread
// execution across asynchronous worker threads without race conditions.
unsafe impl Send for SerializedPdfium {}
// SAFETY: All calls to the internal Pdfium C++ FFI handle are strictly guarded
// by a Mutual Exclusion (Mutex) lock, guaranteeing synchronized multi-thread access.
unsafe impl Sync for SerializedPdfium {}
use std::sync::OnceLock;
static GLOBAL_PDFIUM: OnceLock<Arc<Mutex<SerializedPdfium>>> = OnceLock::new();
/// Maximum dimension (width or height) allowed when rasterizing a PDF page,
/// preventing out-of-memory denial of service attacks from malicious documents.
pub const MAX_PAGE_DIMENSION: f32 = 8192.0;
/// Computes target pixel dimensions preserving the page aspect ratio,
/// scaling down uniformly if either dimension exceeds `MAX_PAGE_DIMENSION`.
pub(crate) fn compute_target_dimensions(
page_width_pts: f32,
page_height_pts: f32,
dpi: f32,
zoom: f32,
) -> (i32, i32) {
let scale_factor = (dpi / 72.0) * zoom;
let raw_w = (page_width_pts * scale_factor).max(1.0);
let raw_h = (page_height_pts * scale_factor).max(1.0);
let clamp_scale = if raw_w > MAX_PAGE_DIMENSION || raw_h > MAX_PAGE_DIMENSION {
(MAX_PAGE_DIMENSION / raw_w).min(MAX_PAGE_DIMENSION / raw_h)
} else {
1.0
};
let target_w = (raw_w * clamp_scale).round().clamp(1.0, MAX_PAGE_DIMENSION) as i32;
let target_h = (raw_h * clamp_scale).round().clamp(1.0, MAX_PAGE_DIMENSION) as i32;
(target_w, target_h)
}
/// Thread-safe wrapper around a Pdfium instance.
#[derive(Clone)]
pub struct PdfiumEngine {
inner: Arc<Mutex<SerializedPdfium>>,
}
impl Default for PdfiumEngine {
fn default() -> Self {
Self::new()
}
}
impl PdfiumEngine {
/// Creates a new engine handle, sharing the process-level singleton instance.
pub fn new() -> Self {
let inner = GLOBAL_PDFIUM
.get_or_init(|| Arc::new(Mutex::new(SerializedPdfium(Self::init_pdfium()))))
.clone();
Self { inner }
}
/// Attempts to bind to dynamic `libpdfium.so`, `libpdfium.dylib`, or `pdfium.dll`.
fn init_pdfium() -> Option<Pdfium> {
// 1. Try explicit environment variable PDFIUM_LIB_PATH
if let Ok(env_path) = std::env::var("PDFIUM_LIB_PATH") {
let path = Path::new(&env_path);
if path.exists() {
if let Ok(bindings) = Pdfium::bind_to_library(path) {
log::info!(
"Successfully bound to Pdfium from environment at {}",
env_path
);
return Some(Pdfium::new(bindings));
}
}
}
// 2. Try standard system library search path
if let Ok(bindings) = Pdfium::bind_to_system_library() {
log::info!("Successfully initialized Pdfium from system dynamic library");
return Some(Pdfium::new(bindings));
}
// 3. Try common Linux/macOS shared object locations
let mut common_paths: Vec<PathBuf> = vec![
PathBuf::from("lib/libpdfium.so"),
PathBuf::from("./lib/libpdfium.so"),
PathBuf::from("../lib/libpdfium.so"),
PathBuf::from("../../lib/libpdfium.so"),
];
if let Ok(home_dir) = std::env::var("HOME") {
if !home_dir.trim().is_empty() {
common_paths.push(PathBuf::from(format!("{home_dir}/.local/lib/libpdfium.so")));
common_paths.push(PathBuf::from(format!(
"{home_dir}/.local/lib/libpdfium.dylib"
)));
}
}
common_paths.extend([
PathBuf::from("/usr/lib/libpdfium.so"),
PathBuf::from("/usr/lib64/libpdfium.so"),
PathBuf::from("/usr/local/lib/libpdfium.so"),
PathBuf::from("/opt/homebrew/lib/libpdfium.dylib"),
PathBuf::from("/usr/local/lib/libpdfium.dylib"),
]);
for path in &common_paths {
if path.exists() {
if let Ok(bindings) = Pdfium::bind_to_library(path) {
log::info!("Successfully bound to Pdfium at {}", path.display());
return Some(Pdfium::new(bindings));
}
}
}
log::warn!("Dynamic libpdfium not found in standard system paths; engine will use synthetic fallback");
None
}
/// True if native Pdfium C++ bindings are actively loaded.
pub fn is_native_available(&self) -> bool {
self.inner.lock().0.is_some()
}
/// Loads a PDF from disk path and returns document metadata.
pub fn load_document_from_path(&self, path: &Path) -> Result<PdfDocument> {
let bytes = std::fs::read(path)
.with_context(|| format!("Failed to read PDF file at {:?}", path))?;
self.load_document_from_bytes(&bytes, Some(path.to_path_buf()))
}
/// Loads a PDF from in-memory byte slice.
pub fn load_document_from_bytes(
&self,
bytes: &[u8],
path: Option<PathBuf>,
) -> Result<PdfDocument> {
let guard = self.inner.lock();
if let Some(ref pdfium) = guard.0 {
let doc = pdfium
.load_pdf_from_byte_slice(bytes, None)
.context("Pdfium failed to parse PDF document byte slice")?;
let mut pages = Vec::new();
for page in doc.pages().iter() {
let width = page.width().value;
let height = page.height().value;
pages.push(PageDimensions::new(width, height));
}
let title = doc
.metadata()
.get(PdfDocumentMetadataTagType::Title)
.map(|s| s.value().to_string());
Ok(PdfDocument::new(path, pages, title))
} else {
// Fallback scanner if native library is absent
PdfDocument::from_bytes(bytes, path)
}
}
/// Rasterizes a specific page from file bytes into an RGBA frame buffer.
pub fn render_page_from_bytes(
&self,
bytes: &[u8],
page_index: usize,
options: RasterizerOptions,
) -> Result<RenderedPage> {
let guard = self.inner.lock();
if let Some(ref pdfium) = guard.0 {
let doc = pdfium
.load_pdf_from_byte_slice(bytes, None)
.context("Failed to load PDF in rasterizer")?;
let page = doc
.pages()
.get(page_index as u16)
.context("Requested page index out of bounds")?;
let (target_width, target_height) = compute_target_dimensions(
page.width().value,
page.height().value,
options.target_dpi,
options.zoom_factor,
);
let render_config = PdfRenderConfig::new()
.set_target_width(target_width)
.set_target_height(target_height)
.render_form_data(true)
.render_annotations(true);
let bitmap = page
.render_with_config(&render_config)
.context("Pdfium page render call failed")?;
let mut rgba_buffer = bitmap.as_image().to_rgba8().into_raw();
if options.dark_mode {
LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold);
}
Ok(RenderedPage::new(
page_index,
target_width as u32,
target_height as u32,
options.zoom_factor,
options.dark_mode,
rgba_buffer,
))
} else {
// Fallback mock rendering when native engine binary is not linked
let dim = PageDimensions::new(612.0, 792.0);
crate::rasterizer::PageRasterizer::render_mock_page(page_index, dim, options)
}
}
/// Rasterizes all pages from file bytes into a continuous vertical layout,
/// stitching them together with a configurable page spacing and margin.
pub fn render_document_from_bytes(
&self,
bytes: &[u8],
options: RasterizerOptions,
page_gap: u32,
) -> Result<RenderedPage> {
let guard = self.inner.lock();
if let Some(ref pdfium) = guard.0 {
let doc = pdfium
.load_pdf_from_byte_slice(bytes, None)
.context("Failed to load PDF in document rasterizer")?;
let total_pages = doc.pages().len() as usize;
if total_pages == 0 {
anyhow::bail!("PDF document contains no pages");
}
let mut rendered_pages = Vec::with_capacity(total_pages);
for (idx, page) in doc.pages().iter().enumerate() {
let (target_width, target_height) = compute_target_dimensions(
page.width().value,
page.height().value,
options.target_dpi,
options.zoom_factor,
);
let render_config = PdfRenderConfig::new()
.set_target_width(target_width)
.set_target_height(target_height)
.render_form_data(true)
.render_annotations(true);
let bitmap = page
.render_with_config(&render_config)
.with_context(|| format!("Pdfium failed to render page {}", idx))?;
let mut rgba_buffer = bitmap.as_image().to_rgba8().into_raw();
if options.dark_mode {
LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold);
}
rendered_pages.push((target_width as u32, target_height as u32, rgba_buffer));
}
let max_width = rendered_pages.iter().map(|(w, _, _)| *w).max().unwrap_or(1);
let total_height: u32 = rendered_pages.iter().map(|(_, h, _)| *h).sum::<u32>()
+ ((total_pages as u32 - 1) * page_gap);
let stride = (max_width as usize) * 4;
let total_bytes = stride * (total_height as usize);
let bg_color = if options.dark_mode {
[24u8, 24, 37, 255]
} else {
[230u8, 233, 239, 255]
};
let mut composite = vec![0u8; total_bytes];
for chunk in composite.as_chunks_mut::<4>().0 {
*chunk = bg_color;
}
let mut y_offset = 0u32;
for (w, h, page_buf) in rendered_pages {
let x_offset = ((max_width - w) / 2) as usize;
let page_stride = (w as usize) * 4;
for y in 0..h {
let src_start = (y as usize) * page_stride;
let src_end = src_start + page_stride;
let src_slice = &page_buf[src_start..src_end];
let dst_y = (y_offset + y) as usize;
let dst_start = (dst_y * (max_width as usize) + x_offset) * 4;
let dst_end = dst_start + page_stride;
composite[dst_start..dst_end].copy_from_slice(src_slice);
}
y_offset += h + page_gap;
}
Ok(RenderedPage::new(
0,
max_width,
total_height,
options.zoom_factor,
options.dark_mode,
composite,
))
} else {
let doc = PdfDocument::from_bytes(bytes, None)?;
let total_pages = doc.total_pages();
if total_pages == 0 {
anyhow::bail!("PDF document contains no pages");
}
if total_pages == 1 {
let dim = doc
.page_size(0)
.unwrap_or(PageDimensions::new(612.0, 792.0));
return PageRasterizer::render_mock_page(0, dim, options);
}
let mut rendered_pages = Vec::with_capacity(total_pages);
for i in 0..total_pages {
let dim = doc
.page_size(i)
.unwrap_or(PageDimensions::new(612.0, 792.0));
let page = PageRasterizer::render_mock_page(i, dim, options)?;
rendered_pages.push((page.width, page.height, page.rgba_buffer.as_ref().clone()));
}
let max_width = rendered_pages.iter().map(|(w, _, _)| *w).max().unwrap_or(1);
let total_height: u32 = rendered_pages.iter().map(|(_, h, _)| *h).sum::<u32>()
+ ((total_pages as u32 - 1) * page_gap);
let stride = (max_width as usize) * 4;
let total_bytes = stride * (total_height as usize);
let bg_color = if options.dark_mode {
[24u8, 24, 37, 255]
} else {
[230u8, 233, 239, 255]
};
let mut composite = vec![0u8; total_bytes];
for chunk in composite.as_chunks_mut::<4>().0 {
*chunk = bg_color;
}
let mut y_offset = 0u32;
for (w, h, page_buf) in rendered_pages {
let x_offset = ((max_width - w) / 2) as usize;
let page_stride = (w as usize) * 4;
for y in 0..h {
let src_start = (y as usize) * page_stride;
let src_end = src_start + page_stride;
let src_slice = &page_buf[src_start..src_end];
let dst_y = (y_offset + y) as usize;
let dst_start = (dst_y * (max_width as usize) + x_offset) * 4;
let dst_end = dst_start + page_stride;
composite[dst_start..dst_end].copy_from_slice(src_slice);
}
y_offset += h + page_gap;
}
Ok(RenderedPage::new(
0,
max_width,
total_height,
options.zoom_factor,
options.dark_mode,
composite,
))
}
}
/// Extracts text from a specific page (0-indexed) or the entire document if page_index is None.
pub fn extract_text_from_bytes(
&self,
bytes: &[u8],
page_index: Option<usize>,
) -> Result<String> {
let guard = self.inner.lock();
if let Some(ref pdfium) = guard.0 {
let doc = pdfium
.load_pdf_from_byte_slice(bytes, None)
.context("Failed to load PDF for text extraction")?;
if let Some(idx) = page_index {
let page = doc
.pages()
.get(idx as u16)
.context("Requested page index out of bounds")?;
let text_page = page.text().context("Failed to load page text")?;
Ok(text_page.all())
} else {
let mut full_text = String::new();
for (idx, page) in doc.pages().iter().enumerate() {
if let Ok(text_page) = page.text() {
let page_text = text_page.all();
if !page_text.is_empty() {
if idx > 0 {
full_text.push_str("\n\n--- Page ");
full_text.push_str(&(idx + 1).to_string());
full_text.push_str(" ---\n\n");
}
full_text.push_str(&page_text);
}
}
}
Ok(full_text)
}
} else {
Ok(String::new())
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PdfLinkAnnotation {
pub url: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PdfTextSegment {
pub text: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Default)]
pub struct PdfPageDetails {
pub page_index: usize,
pub width_pt: f32,
pub height_pt: f32,
pub text: String,
pub links: Vec<PdfLinkAnnotation>,
pub text_segments: Vec<PdfTextSegment>,
}
#[derive(Debug, Clone, Default)]
pub struct PdfDocumentDetails {
pub total_pages: usize,
pub full_text: String,
pub pages: Vec<PdfPageDetails>,
}
#[derive(Debug, Clone)]
pub struct PdfPageRenderResult {
pub page_index: usize,
pub width: u32,
pub height: u32,
pub rgba_buffer: Vec<u8>,
pub text: String,
pub text_segments: Vec<PdfTextSegment>,
pub links: Vec<PdfLinkAnnotation>,
}
#[derive(Debug, Clone, Default)]
pub struct PdfDocumentRenderResult {
pub total_pages: usize,
pub full_text: String,
pub pages: Vec<PdfPageRenderResult>,
}
#[inline]
fn normalize_coordinates(
left: f32,
right: f32,
bottom: f32,
top: f32,
page_w: f32,
page_h: f32,
) -> (f32, f32, f32, f32) {
if page_w <= 0.0 || page_h <= 0.0 {
return (0.0, 0.0, 0.0, 0.0);
}
let norm_x = (left / page_w).clamp(0.0, 1.0);
let norm_y = ((page_h - top) / page_h).clamp(0.0, 1.0);
let norm_w = ((right - left) / page_w).clamp(0.0, 1.0);
let norm_h = ((top - bottom) / page_h).clamp(0.0, 1.0);
(norm_x, norm_y, norm_w, norm_h)
}
fn extract_page_text_and_links(
page: &pdfium_render::prelude::PdfPage,
idx: usize,
full_text: &mut String,
) -> (String, Vec<PdfTextSegment>, Vec<PdfLinkAnnotation>) {
let page_w = page.width().value;
let page_h = page.height().value;
let mut page_text = String::new();
let mut segments = Vec::new();
let mut links = Vec::new();
// 1. Text and Text Segments
if let Ok(text_page) = page.text() {
page_text = text_page.all();
if !page_text.is_empty() {
if idx > 0 {
full_text.push_str("\n\n--- Page ");
full_text.push_str(&(idx + 1).to_string());
full_text.push_str(" ---\n\n");
}
full_text.push_str(&page_text);
}
let seg_coll = text_page.segments();
for seg in seg_coll.iter() {
let seg_str = seg.text();
let seg_trimmed = seg_str.trim();
if seg_trimmed.is_empty() {
continue;
}
if !seg_trimmed.contains(' ') {
let bounds = seg.bounds();
let left = bounds.left().value.min(bounds.right().value);
let right = bounds.left().value.max(bounds.right().value);
let bottom = bounds.bottom().value.min(bounds.top().value);
let top = bounds.bottom().value.max(bounds.top().value);
let (norm_x, norm_y, norm_w, norm_h) =
normalize_coordinates(left, right, bottom, top, page_w, page_h);
if norm_w > 0.0 && norm_h > 0.0 {
segments.push(PdfTextSegment {
text: seg_trimmed.to_string(),
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
continue;
}
if let Ok(chars) = seg.chars() {
let mut word_text = String::new();
let mut min_left = f32::MAX;
let mut max_right = f32::MIN;
let mut min_bottom = f32::MAX;
let mut max_top = f32::MIN;
let mut has_word_char = false;
for ch in chars.iter() {
let u_char = ch.unicode_char();
let is_ws = u_char.map(|c| c.is_whitespace()).unwrap_or(false);
if is_ws {
if has_word_char && !word_text.is_empty() {
let (norm_x, norm_y, norm_w, norm_h) = normalize_coordinates(
min_left, max_right, min_bottom, max_top, page_w, page_h,
);
if norm_w > 0.0 && norm_h > 0.0 {
segments.push(PdfTextSegment {
text: std::mem::take(&mut word_text),
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
word_text.clear();
min_left = f32::MAX;
max_right = f32::MIN;
min_bottom = f32::MAX;
max_top = f32::MIN;
has_word_char = false;
}
} else if let Some(c) = u_char {
word_text.push(c);
if let Ok(bounds) = ch.loose_bounds().or_else(|_| ch.tight_bounds()) {
let l = bounds.left().value.min(bounds.right().value);
let r = bounds.left().value.max(bounds.right().value);
let b = bounds.bottom().value.min(bounds.top().value);
let t = bounds.bottom().value.max(bounds.top().value);
min_left = min_left.min(l);
max_right = max_right.max(r);
min_bottom = min_bottom.min(b);
max_top = max_top.max(t);
has_word_char = true;
}
}
}
if has_word_char && !word_text.is_empty() {
let (norm_x, norm_y, norm_w, norm_h) = normalize_coordinates(
min_left, max_right, min_bottom, max_top, page_w, page_h,
);
if norm_w > 0.0 && norm_h > 0.0 {
segments.push(PdfTextSegment {
text: word_text,
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
}
} else {
let bounds = seg.bounds();
let left = bounds.left().value.min(bounds.right().value);
let right = bounds.left().value.max(bounds.right().value);
let bottom = bounds.bottom().value.min(bounds.top().value);
let top = bounds.bottom().value.max(bounds.top().value);
let (norm_x, norm_y, norm_w, norm_h) =
normalize_coordinates(left, right, bottom, top, page_w, page_h);
segments.push(PdfTextSegment {
text: seg_trimmed.to_string(),
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
}
}
// 2. Links from page.links()
for link in page.links().iter() {
if let Some(PdfAction::Uri(uri_action)) = link.action() {
if let Ok(url) = uri_action.uri() {
let url_clean = url.trim().to_string();
if !url_clean.is_empty() {
if let Ok(rect) = link.rect() {
let left = rect.left().value.min(rect.right().value);
let right = rect.left().value.max(rect.right().value);
let bottom = rect.bottom().value.min(rect.top().value);
let top = rect.bottom().value.max(rect.top().value);
let (norm_x, norm_y, norm_w, norm_h) =
normalize_coordinates(left, right, bottom, top, page_w, page_h);
links.push(PdfLinkAnnotation {
url: url_clean,
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
}
}
}
}
// 3. Links from page.annotations()
for annot in page.annotations().iter() {
if let Some(link_annot) = annot.as_link_annotation() {
if let Ok(link) = link_annot.link() {
if let Some(PdfAction::Uri(uri_action)) = link.action() {
if let Ok(url) = uri_action.uri() {
let url_clean = url.trim().to_string();
if !url_clean.is_empty() {
if let Ok(rect) = link.rect() {
let left = rect.left().value.min(rect.right().value);
let right = rect.left().value.max(rect.right().value);
let bottom = rect.bottom().value.min(rect.top().value);
let top = rect.bottom().value.max(rect.top().value);
let (norm_x, norm_y, norm_w, norm_h) =
normalize_coordinates(left, right, bottom, top, page_w, page_h);
let exists = links.iter().any(|l| {
l.url == url_clean
&& (l.x - norm_x).abs() < 0.01
&& (l.y - norm_y).abs() < 0.01
});
if !exists {
links.push(PdfLinkAnnotation {
url: url_clean,
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
}
}
}
}
}
}
}
// 4. Text URLs from segments (heuristic for raw text URLs)
for seg in &segments {
let text = seg.text.trim();
if text.starts_with("http://") || text.starts_with("https://") || text.starts_with("www.") {
let url = if text.starts_with("www.") {
format!("https://{}", text)
} else {
text.to_string()
};
let exists = links
.iter()
.any(|l| (l.x - seg.x).abs() < 0.02 && (l.y - seg.y).abs() < 0.02);
if !exists {
links.push(PdfLinkAnnotation {
url,
x: seg.x,
y: seg.y,
width: seg.width,
height: seg.height,
});
}
}
}
(page_text, segments, links)
}
impl PdfiumEngine {
/// Extracts full document details including text, text segments with bounding boxes, and links.
pub fn extract_document_details(&self, bytes: &[u8]) -> Result<PdfDocumentDetails> {
let guard = self.inner.lock();
if let Some(ref pdfium) = guard.0 {
let doc = pdfium
.load_pdf_from_byte_slice(bytes, None)
.context("Failed to load PDF for metadata extraction")?;
let mut pages_details = Vec::new();
let mut full_text = String::new();
for (idx, page) in doc.pages().iter().enumerate() {
let page_w = page.width().value;
let page_h = page.height().value;
let (page_text, segments, links) =
extract_page_text_and_links(&page, idx, &mut full_text);
pages_details.push(PdfPageDetails {
page_index: idx,
width_pt: page_w,
height_pt: page_h,
text: page_text,
links,
text_segments: segments,
});
}
Ok(PdfDocumentDetails {
total_pages: pages_details.len(),
full_text,
pages: pages_details,
})
} else {
Ok(PdfDocumentDetails::default())
}
}
/// Renders all pages and extracts text/links in a single fast pass over the loaded document.
pub fn render_and_extract_document_from_bytes(
&self,
bytes: &[u8],
options: RasterizerOptions,
) -> Result<PdfDocumentRenderResult> {
let guard = self.inner.lock();
if let Some(ref pdfium) = guard.0 {
let doc = pdfium
.load_pdf_from_byte_slice(bytes, None)
.context("Failed to load PDF for render and extraction")?;
let total_pages = doc.pages().len() as usize;
let mut full_text = String::new();
let mut rendered_pages = Vec::with_capacity(total_pages);
for (idx, page) in doc.pages().iter().enumerate() {
let page_w = page.width().value;
let page_h = page.height().value;
let (target_width, target_height) = compute_target_dimensions(
page_w,
page_h,
options.target_dpi,
options.zoom_factor,
);
let render_config = PdfRenderConfig::new()
.set_target_width(target_width)
.set_target_height(target_height)
.render_form_data(true)
.render_annotations(true);
let bitmap = page
.render_with_config(&render_config)
.with_context(|| format!("Pdfium failed to render page {}", idx))?;
let mut rgba_buffer = bitmap.as_image().to_rgba8().into_raw();
if options.dark_mode {
LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold);
}
let (text, text_segments, links) =
extract_page_text_and_links(&page, idx, &mut full_text);
rendered_pages.push(PdfPageRenderResult {
page_index: idx,
width: target_width as u32,
height: target_height as u32,
rgba_buffer,
text,
text_segments,
links,
});
}
Ok(PdfDocumentRenderResult {
total_pages: rendered_pages.len(),
full_text,
pages: rendered_pages,
})
} else {
let dim = PageDimensions::new(612.0, 792.0);
let page = crate::rasterizer::PageRasterizer::render_mock_page(0, dim, options)?;
Ok(PdfDocumentRenderResult {
total_pages: 1,
full_text: String::new(),
pages: vec![PdfPageRenderResult {
page_index: 0,
width: page.width,
height: page.height,
rgba_buffer: page.rgba_buffer.as_ref().clone(),
text: String::new(),
text_segments: Vec::new(),
links: Vec::new(),
}],
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_engine_initialization_safety() {
let engine = PdfiumEngine::new();
// Even without system libpdfium installed, instance must initialize safely
let is_avail = engine.is_native_available();
let _ = is_avail; // No panic
}
#[test]
fn test_fallback_rendering_when_native_absent() {
let engine = PdfiumEngine {
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
};
let dummy_pdf = b"%PDF-1.7\nSample";
let doc = engine
.load_document_from_bytes(dummy_pdf, None)
.expect("Fallback parse failed");
assert_eq!(doc.total_pages(), 1);
let opts = RasterizerOptions::default();
let page = engine
.render_page_from_bytes(dummy_pdf, 0, opts)
.expect("Fallback render failed");
assert!(page.width > 0);
assert!(page.height > 0);
assert_eq!(
page.rgba_buffer.len(),
(page.width * page.height * 4) as usize
);
}
#[test]
fn test_render_document_multi_page() {
let engine = PdfiumEngine {
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
};
let dummy_pdf = b"%PDF-1.7\nSample";
let opts = RasterizerOptions::default();
let rendered = engine
.render_document_from_bytes(dummy_pdf, opts, 20)
.expect("Render document failed");
assert!(rendered.width > 0);
assert!(rendered.height > 0);
assert_eq!(
rendered.rgba_buffer.len(),
(rendered.width * rendered.height * 4) as usize
);
}
#[test]
fn test_extract_document_details() {
let engine = PdfiumEngine {
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
};
let dummy_pdf = b"%PDF-1.7\nSample";
let details = engine.extract_document_details(dummy_pdf);
assert!(details.is_ok());
let doc = details.unwrap();
assert_eq!(doc.total_pages, 0);
}
#[test]
fn test_render_and_extract_document_from_bytes() {
let engine = PdfiumEngine {
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
};
let dummy_pdf = b"%PDF-1.7\nSample";
let opts = RasterizerOptions::default();
let res = engine.render_and_extract_document_from_bytes(dummy_pdf, opts);
assert!(res.is_ok());
let doc = res.unwrap();
assert_eq!(doc.total_pages, 1);
assert_eq!(doc.pages.len(), 1);
assert!(doc.pages[0].width > 0);
assert!(doc.pages[0].height > 0);
}
#[test]
fn test_max_page_dimension_bounds() {
assert_eq!(MAX_PAGE_DIMENSION, 8192.0);
// Verify clamping logic for extreme/malicious dimensions
let huge_dim: f32 = 100_000.0;
let clamped = huge_dim.clamp(1.0, MAX_PAGE_DIMENSION);
assert_eq!(clamped, 8192.0);
let tiny_dim: f32 = -50.0;
let clamped_tiny = tiny_dim.clamp(1.0, MAX_PAGE_DIMENSION);
assert_eq!(clamped_tiny, 1.0);
// Verify uniform aspect ratio scaling under extreme dimensions
let (w, h) = compute_target_dimensions(10_000.0, 2_000.0, 72.0, 1.0);
assert_eq!(w, 8192);
assert_eq!(h, 1638);
assert!(((w as f32 / h as f32) - 5.0).abs() < 0.01);
let (w2, h2) = compute_target_dimensions(2_000.0, 10_000.0, 72.0, 1.0);
assert_eq!(w2, 1638);
assert_eq!(h2, 8192);
assert!(((h2 as f32 / w2 as f32) - 5.0).abs() < 0.01);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Async PDF page rasterization engine and luminosity tone-mapper.
//!
//! Converts PDF vector pages into RGBA framebuffers on background worker threads,
//! with optional luminance-threshold dark mode color mapping.
use crate::cache::RenderedPage;
use crate::document::PageDimensions;
use anyhow::Result;
/// Rendering and tone mapping configuration.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RasterizerOptions {
/// Screen or viewport DPI scale (default 72.0 for 1x, 144.0 for 2x Retina).
pub target_dpi: f32,
/// User zoom magnification multiplier (1.0 = 100%).
pub zoom_factor: f32,
/// Whether dark mode tone mapping is active.
pub dark_mode: bool,
/// Saturation threshold (0.0 to 1.0) above which colors are preserved (default 0.18).
pub saturation_threshold: f32,
}
impl Default for RasterizerOptions {
fn default() -> Self {
Self {
target_dpi: 144.0, // High-DPI default for crisp font rendering
zoom_factor: 1.0,
dark_mode: false,
saturation_threshold: 0.18,
}
}
}
/// Luminosity-preserving color remapper for dark themes.
///
/// Unlike naive RGB inversion (`255 - C`) which turns colored photos, figures,
/// and syntax-highlighted diagrams into unusable photo-negatives, this remapper:
/// 1. Computes ITU-R BT.709 relative luminance $Y = 0.2126 R + 0.7152 G + 0.0722 B$.
/// 2. Calculates color saturation $S = \frac{\max(R,G,B) - \min(R,G,B)}{\max(R,G,B)}$.
/// 3. If $S < \text{threshold}$ (neutral paper background or black ink text):
/// - Inverts luminance to match dark theme surface background (`#1E1E2E`) and light text (`#CDD6F4`).
/// 4. If $S \ge \text{threshold}$ (colored graphics, charts, photos):
/// - Leaves pixel RGB channels untouched!
pub struct LuminosityToneMapper;
impl LuminosityToneMapper {
/// Applies dark mode tone mapping in-place on an RGBA8 buffer.
pub fn apply(rgba_buffer: &mut [u8], saturation_threshold: f32) {
// Chunk by 4 bytes: [R, G, B, A]
for pixel in rgba_buffer.as_chunks_mut::<4>().0 {
let r = pixel[0] as f32;
let g = pixel[1] as f32;
let b = pixel[2] as f32;
let a = pixel[3];
// If fully transparent, skip
if a == 0 {
continue;
}
let max_c = r.max(g).max(b);
let min_c = r.min(g).min(b);
let saturation = if max_c > 0.0 {
(max_c - min_c) / max_c
} else {
0.0
};
// Only recolor desaturated / neutral pixels (paper backgrounds and text)
if saturation < saturation_threshold {
// ITU-R BT.709 standard relative luminance
let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
// Remap luminance: 255 (pure white) -> 30 (dark background), 0 (pure black) -> 215 (light text)
// Linear transformation: target_lum = 215.0 - (lum / 255.0) * 185.0
let target_lum = (215.0 - (lum / 255.0) * 185.0).clamp(0.0, 255.0) as u8;
pixel[0] = target_lum;
pixel[1] = target_lum;
pixel[2] = target_lum;
}
}
}
}
/// Core rasterization pipeline for generating page bitmaps.
pub struct PageRasterizer;
impl PageRasterizer {
/// Rasterizes a synthetic or mock page buffer for testing and fallback.
pub fn render_mock_page(
page_index: usize,
dimensions: PageDimensions,
options: RasterizerOptions,
) -> Result<RenderedPage> {
let (width, height) = dimensions.to_pixel_size(options.zoom_factor, options.target_dpi);
let total_bytes = (width as usize) * (height as usize) * 4;
// Create standard white paper background with an inner border
let mut buffer = vec![255u8; total_bytes];
// Draw a simulated dark page header box
for y in 0..height.min(30) {
for x in 0..width {
let idx = ((y * width + x) * 4) as usize;
buffer[idx] = 20; // R
buffer[idx + 1] = 20; // G
buffer[idx + 2] = 20; // B
buffer[idx + 3] = 255; // A
}
}
// Draw a vibrant red accent icon box (tests saturation preservation)
for y in 40..height.min(80) {
for x in 40..width.min(80) {
let idx = ((y * width + x) * 4) as usize;
buffer[idx] = 235; // R (Vibrant Red)
buffer[idx + 1] = 30; // G
buffer[idx + 2] = 30; // B
buffer[idx + 3] = 255;
}
}
if options.dark_mode {
LuminosityToneMapper::apply(&mut buffer, options.saturation_threshold);
}
Ok(RenderedPage::new(
page_index,
width,
height,
options.zoom_factor,
options.dark_mode,
buffer,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_luminosity_remapping_white_and_black() {
// Pixel 0: Pure White [255, 255, 255, 255] (Paper background)
// Pixel 1: Pure Black [0, 0, 0, 255] (Text)
let mut buffer = vec![255, 255, 255, 255, 0, 0, 0, 255];
LuminosityToneMapper::apply(&mut buffer, 0.18);
// White should be mapped to dark background (~30)
assert!(
buffer[0] <= 40,
"White background should become dark (got {})",
buffer[0]
);
assert_eq!(buffer[0], buffer[1]);
assert_eq!(buffer[1], buffer[2]);
// Black should be mapped to light text (~215)
assert!(
buffer[4] >= 200,
"Black text should become light (got {})",
buffer[4]
);
assert_eq!(buffer[4], buffer[5]);
assert_eq!(buffer[5], buffer[6]);
}
#[test]
fn test_luminosity_remapping_preserves_saturated_colors() {
// Pixel 0: Saturated Red [255, 0, 0, 255]
// Pixel 1: Saturated Blue [0, 120, 255, 255]
let mut buffer = vec![255, 0, 0, 255, 0, 120, 255, 255];
LuminosityToneMapper::apply(&mut buffer, 0.18);
// Saturated red must be preserved
assert_eq!(buffer[0], 255);
assert_eq!(buffer[1], 0);
assert_eq!(buffer[2], 0);
// Saturated blue must be preserved
assert_eq!(buffer[4], 0);
assert_eq!(buffer[5], 120);
assert_eq!(buffer[6], 255);
}
#[test]
fn test_mock_rasterizer_output_sanity() {
let dim = PageDimensions::new(600.0, 800.0);
let opts = RasterizerOptions {
target_dpi: 72.0,
zoom_factor: 1.0,
dark_mode: false,
saturation_threshold: 0.18,
};
let page = PageRasterizer::render_mock_page(0, dim, opts).expect("Mock render failed");
assert_eq!(page.width, 600);
assert_eq!(page.height, 800);
assert_eq!(page.rgba_buffer.len(), 600 * 800 * 4);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Configuration and user preferences for the PDF viewer.
use serde::{Deserialize, Serialize};
/// Layout and display mode for viewing pages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PageLayoutMode {
/// Continuous vertical scrolling of all pages.
#[default]
Continuous,
/// Single page view with explicit page turn controls.
SinglePage,
/// Side-by-side two-page spread (book mode).
TwoPageSpread,
}
/// Default zoom policy when opening a document.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DefaultZoomPolicy {
/// Fit page width to current pane width.
#[default]
FitWidth,
/// Fit entire page height and width inside pane.
FitPage,
/// 100% actual print size (72 DPI).
ActualSize,
}
/// PDF viewer settings configuration structure.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PdfViewerSettings {
/// Initial zoom behavior when opening a document.
pub default_zoom: DefaultZoomPolicy,
/// Page layout mode (continuous scroll, single page, or two-page spread).
pub layout_mode: PageLayoutMode,
/// Automatically apply smart luminosity-preserving dark mode when dark theme is active.
pub smart_dark_mode: bool,
/// Saturation threshold (0.0 to 1.0) for dark mode tone mapper. Saturated pixels (>= threshold) are kept untouched.
pub saturation_threshold: f32,
/// Memory cache budget in megabytes for pre-rendered page bitmaps.
pub cache_budget_mb: usize,
/// Debounce duration in milliseconds for hot-reloading when PDF file changes on disk.
pub reload_debounce_ms: u64,
/// High-DPI rasterization multiplier for ultra-crisp typography.
pub target_dpi: f32,
}
impl Default for PdfViewerSettings {
fn default() -> Self {
Self {
default_zoom: DefaultZoomPolicy::FitWidth,
layout_mode: PageLayoutMode::Continuous,
smart_dark_mode: true,
saturation_threshold: 0.18,
cache_budget_mb: 256,
reload_debounce_ms: 200,
target_dpi: 144.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_settings_serialization() {
let settings = PdfViewerSettings::default();
let json = serde_json::to_string(&settings).unwrap_or_else(|_| "{}".into());
assert!(json.contains("fit_width"));
assert!(json.contains("continuous"));
}
}
+102
View File
@@ -0,0 +1,102 @@
//! User interface components and toolbar state models for PDF viewing.
//!
//! Provides navigation, zoom presets, page jump controls, and dark mode toggles.
use crate::settings::PageLayoutMode;
/// User actions emitted from toolbar buttons and inputs.
#[derive(Debug, Clone, PartialEq)]
pub enum PdfToolbarAction {
ZoomIn,
ZoomOut,
ResetZoom,
FitToWidth,
FitToPage,
SetZoomPercentage(u32),
PreviousPage,
NextPage,
GoToPage(usize),
ToggleDarkMode,
SetLayoutMode(PageLayoutMode),
Reload,
}
/// Read-only snapshot of viewer state consumed by the toolbar UI renderer.
#[derive(Debug, Clone, PartialEq)]
pub struct PdfToolbarState {
/// Active 1-indexed page number.
pub current_page: usize,
/// Total pages in document.
pub total_pages: usize,
/// Current zoom percentage (e.g. 100 for 100%).
pub zoom_percentage: u32,
/// Whether smart dark mode tone mapping is enabled.
pub dark_mode: bool,
/// Current layout mode.
pub layout_mode: PageLayoutMode,
/// Whether previous page action is available.
pub can_prev_page: bool,
/// Whether next page action is available.
pub can_next_page: bool,
}
impl PdfToolbarState {
/// Constructs a toolbar state from active parameters.
pub fn new(
current_page: usize,
total_pages: usize,
zoom_percentage: u32,
dark_mode: bool,
layout_mode: PageLayoutMode,
) -> Self {
let can_prev_page = current_page > 1;
let can_next_page = total_pages > 0 && current_page < total_pages;
Self {
current_page,
total_pages,
zoom_percentage,
dark_mode,
layout_mode,
can_prev_page,
can_next_page,
}
}
/// Returns formatted page status string (e.g. "Page 3 of 42").
pub fn page_display_text(&self) -> String {
if self.total_pages == 0 {
"0 / 0".to_string()
} else {
format!("{} / {}", self.current_page, self.total_pages)
}
}
/// Returns formatted zoom percentage string (e.g. "125%").
pub fn zoom_display_text(&self) -> String {
format!("{}%", self.zoom_percentage)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_toolbar_state_text_and_navigation_flags() {
let state = PdfToolbarState::new(1, 10, 100, false, PageLayoutMode::Continuous);
assert_eq!(state.page_display_text(), "1 / 10");
assert_eq!(state.zoom_display_text(), "100%");
assert!(!state.can_prev_page);
assert!(state.can_next_page);
let mid_state = PdfToolbarState::new(5, 10, 150, true, PageLayoutMode::SinglePage);
assert!(mid_state.can_prev_page);
assert!(mid_state.can_next_page);
assert_eq!(mid_state.zoom_display_text(), "150%");
let last_state = PdfToolbarState::new(10, 10, 100, false, PageLayoutMode::Continuous);
assert!(last_state.can_prev_page);
assert!(!last_state.can_next_page);
}
}
+483
View File
@@ -0,0 +1,483 @@
//! Core PDF viewer view state, interaction handlers, and GPUI layout calculations.
use anyhow::{Context as _, Result};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::cache::{CacheKey, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES};
use crate::document::{PageDimensions, PdfDocument};
use crate::pdfium::PdfiumEngine;
use crate::rasterizer::RasterizerOptions;
use crate::settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings};
use crate::watcher::{PdfReloadDebouncer, ViewerStateSnapshot};
pub const MIN_ZOOM: f32 = 0.1;
pub const MAX_ZOOM: f32 = 20.0;
pub const ZOOM_STEP: f32 = 1.15;
pub const PAGE_SPACING_PX: f32 = 16.0;
/// Viewer event emitted to notify subscribers (e.g. tabs, breadcrumbs, toolbar).
#[derive(Debug, Clone, PartialEq)]
pub enum PdfViewEvent {
/// Document title or filename changed.
TitleChanged,
/// Active page changed.
PageChanged(usize),
/// Active zoom level changed.
ZoomChanged(f32),
/// Document successfully reloaded from disk.
Reloaded,
}
/// Core state representation for an interactive PDF viewing session.
pub struct PdfView {
pub(crate) document: Option<PdfDocument>,
pub(crate) engine: PdfiumEngine,
pub(crate) cache: PageLruCache,
pub(crate) settings: PdfViewerSettings,
pub(crate) raw_bytes: Option<Arc<Vec<u8>>>,
pub(crate) zoom_level: f32,
pub(crate) pan_offset: (f32, f32),
pub(crate) current_page: usize,
pub(crate) dark_mode: bool,
pub(crate) layout_mode: PageLayoutMode,
pub(crate) last_mouse_pos: Option<(f32, f32)>,
pub(crate) container_size: Option<(f32, f32)>,
pub(crate) debouncer: Option<PdfReloadDebouncer>,
}
impl PdfView {
/// Creates a new uninitialized PDF view with default settings.
pub fn new(settings: PdfViewerSettings) -> Self {
let cache_bytes = settings.cache_budget_mb * 1024 * 1024;
Self {
document: None,
engine: PdfiumEngine::new(),
cache: PageLruCache::new(cache_bytes.max(DEFAULT_MEMORY_BUDGET_BYTES)),
zoom_level: 1.0,
pan_offset: (0.0, 0.0),
current_page: 0,
dark_mode: settings.smart_dark_mode,
layout_mode: settings.layout_mode,
last_mouse_pos: None,
container_size: None,
debouncer: None,
raw_bytes: None,
settings,
}
}
/// Loads a PDF document from file path.
pub fn open_file(&mut self, path: &Path) -> Result<()> {
let bytes = std::fs::read(path)
.with_context(|| format!("Failed to read PDF file at {:?}", path))?;
self.debouncer = Some(PdfReloadDebouncer::new(
path.to_path_buf(),
self.settings.reload_debounce_ms,
));
self.load_from_bytes(bytes, Some(path.to_path_buf()))
}
/// Loads a PDF document from raw byte buffer.
pub fn load_from_bytes(&mut self, bytes: Vec<u8>, path: Option<PathBuf>) -> Result<()> {
let arc_bytes = Arc::new(bytes);
let doc = self.engine.load_document_from_bytes(&arc_bytes, path)?;
self.raw_bytes = Some(arc_bytes);
self.document = Some(doc);
self.cache.clear();
self.current_page = 0;
self.pan_offset = (0.0, 0.0);
// Apply default zoom policy
if let Some((width, height)) = self.container_size {
self.apply_default_zoom_policy(width, height);
}
Ok(())
}
/// Reloads the document from disk while preserving the user's viewport state.
pub fn reload_preserving_state(&mut self) -> Result<bool> {
let path = match self.debouncer.as_ref().map(|d| d.path().to_path_buf()) {
Some(p) => p,
None => return Ok(false),
};
let snapshot = self.save_state_snapshot();
self.open_file(&path)?;
self.restore_state_snapshot(snapshot);
if let Some(ref mut d) = self.debouncer {
d.mark_reloaded();
}
Ok(true)
}
/// Captures the current navigation and viewport state.
pub fn save_state_snapshot(&self) -> ViewerStateSnapshot {
ViewerStateSnapshot {
current_page: self.current_page,
scroll_percent_y: 0.0,
scroll_percent_x: 0.0,
zoom_level: self.zoom_level,
pan_x: self.pan_offset.0,
pan_y: self.pan_offset.1,
}
}
/// Restores a captured viewport state.
pub fn restore_state_snapshot(&mut self, snapshot: ViewerStateSnapshot) {
if let Some(ref doc) = self.document {
if !doc.is_empty() {
self.current_page = snapshot
.current_page
.min(doc.total_pages().saturating_sub(1));
}
}
self.zoom_level = snapshot.zoom_level.clamp(MIN_ZOOM, MAX_ZOOM);
self.pan_offset = (snapshot.pan_x, snapshot.pan_y);
}
/// Sets the viewport container size and updates fit-to-width/fit-to-page if on first layout.
pub fn set_container_size(&mut self, width: f32, height: f32) {
let first_layout = self.container_size.is_none();
self.container_size = Some((width, height));
if first_layout {
self.apply_default_zoom_policy(width, height);
}
}
/// Applies the default zoom policy based on container geometry and first page size.
fn apply_default_zoom_policy(&mut self, container_width: f32, container_height: f32) {
let first_page_size = self.document.as_ref().and_then(|d| d.page_size(0));
let Some(page_dim) = first_page_size else {
return;
};
match self.settings.default_zoom {
DefaultZoomPolicy::FitWidth => {
self.zoom_level = self.compute_fit_to_width_zoom(container_width, page_dim.width);
}
DefaultZoomPolicy::FitPage => {
self.zoom_level =
self.compute_fit_to_page_zoom(container_width, container_height, page_dim);
}
DefaultZoomPolicy::ActualSize => {
self.zoom_level = 1.0;
}
}
self.pan_offset = (0.0, 0.0);
}
/// Computes zoom factor that fits page width to available container width.
pub fn compute_fit_to_width_zoom(&self, container_width: f32, page_width_pts: f32) -> f32 {
if page_width_pts <= 0.0 || container_width <= 0.0 {
return 1.0;
}
let padding = 32.0; // Left and right gutter
let available = (container_width - padding).max(100.0);
let target_zoom = available / page_width_pts;
target_zoom.clamp(MIN_ZOOM, MAX_ZOOM)
}
/// Computes zoom factor that fits entire page inside container bounds.
pub fn compute_fit_to_page_zoom(
&self,
container_width: f32,
container_height: f32,
page_dim: PageDimensions,
) -> f32 {
if page_dim.width <= 0.0 || page_dim.height <= 0.0 {
return 1.0;
}
let padding = 32.0;
let scale_x = (container_width - padding).max(100.0) / page_dim.width;
let scale_y = (container_height - padding).max(100.0) / page_dim.height;
scale_x.min(scale_y).clamp(MIN_ZOOM, MAX_ZOOM)
}
/// Zooms in by `ZOOM_STEP`.
pub fn zoom_in(&mut self, center: Option<(f32, f32)>) {
self.set_zoom(self.zoom_level * ZOOM_STEP, center);
}
/// Zooms out by `ZOOM_STEP`.
pub fn zoom_out(&mut self, center: Option<(f32, f32)>) {
self.set_zoom(self.zoom_level / ZOOM_STEP, center);
}
/// Resets zoom level to 100% (1.0).
pub fn reset_zoom(&mut self) {
self.zoom_level = 1.0;
self.pan_offset = (0.0, 0.0);
}
/// Adjusts zoom to fit the current page width.
pub fn fit_to_width(&mut self) {
if let Some((container_w, _)) = self.container_size {
if let Some(dim) = self
.document
.as_ref()
.and_then(|d| d.page_size(self.current_page))
{
self.zoom_level = self.compute_fit_to_width_zoom(container_w, dim.width);
self.pan_offset = (0.0, 0.0);
}
}
}
/// Adjusts zoom to fit the current entire page in view.
pub fn fit_to_page(&mut self) {
if let Some((container_w, container_h)) = self.container_size {
if let Some(dim) = self
.document
.as_ref()
.and_then(|d| d.page_size(self.current_page))
{
self.zoom_level = self.compute_fit_to_page_zoom(container_w, container_h, dim);
self.pan_offset = (0.0, 0.0);
}
}
}
/// Sets zoom with mouse focal point anchoring.
pub fn set_zoom(&mut self, new_zoom: f32, focal_center: Option<(f32, f32)>) {
let old_zoom = self.zoom_level;
let clamped = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM);
if (clamped - old_zoom).abs() < f32::EPSILON {
return;
}
self.zoom_level = clamped;
if let Some((center_x, center_y)) = focal_center {
if let Some((cont_w, cont_h)) = self.container_size {
let rel_x = center_x - cont_w / 2.0;
let rel_y = center_y - cont_h / 2.0;
let mouse_offset_x = rel_x - self.pan_offset.0;
let mouse_offset_y = rel_y - self.pan_offset.1;
let ratio = self.zoom_level / old_zoom;
self.pan_offset.0 += mouse_offset_x * (1.0 - ratio);
self.pan_offset.1 += mouse_offset_y * (1.0 - ratio);
}
}
}
/// Navigates to the next page.
pub fn next_page(&mut self) -> bool {
if let Some(ref doc) = self.document {
if self.current_page + 1 < doc.total_pages() {
self.current_page += 1;
return true;
}
}
false
}
/// Navigates to the previous page.
pub fn previous_page(&mut self) -> bool {
if self.current_page > 0 {
self.current_page -= 1;
return true;
}
false
}
/// Jumps directly to target page index (0-indexed).
pub fn go_to_page(&mut self, page_index: usize) -> bool {
if let Some(ref doc) = self.document {
if page_index < doc.total_pages() {
self.current_page = page_index;
return true;
}
}
false
}
/// Returns true if dark mode luminosity mapping is active.
pub fn dark_mode(&self) -> bool {
self.dark_mode
}
/// Sets dark mode luminosity mapping.
pub fn set_dark_mode(&mut self, dark_mode: bool) {
self.dark_mode = dark_mode;
}
/// Toggles smart dark mode luminosity mapping.
pub fn toggle_dark_mode(&mut self) {
self.dark_mode = !self.dark_mode;
}
/// Renders or fetches cached bitmap for a page.
pub fn get_or_render_page(&mut self, page_index: usize) -> Result<RenderedPage> {
let key = CacheKey::new(page_index, self.zoom_level, self.dark_mode);
if let Some(cached) = self.cache.get(&key) {
return Ok(cached);
}
let options = RasterizerOptions {
target_dpi: self.settings.target_dpi,
zoom_factor: self.zoom_level,
dark_mode: self.dark_mode,
saturation_threshold: self.settings.saturation_threshold,
};
let rendered = if let Some(ref raw) = self.raw_bytes {
self.engine
.render_page_from_bytes(raw, page_index, options)?
} else {
let dim = self
.document
.as_ref()
.and_then(|d| d.page_size(page_index))
.unwrap_or(PageDimensions::new(612.0, 792.0));
crate::rasterizer::PageRasterizer::render_mock_page(page_index, dim, options)?
};
self.cache.insert(key, rendered.clone());
Ok(rendered)
}
/// Returns the total number of pages in the loaded document.
pub fn total_pages(&self) -> usize {
self.document.as_ref().map(|d| d.total_pages()).unwrap_or(0)
}
/// Returns the active 1-indexed page number for UI display.
pub fn display_page_number(&self) -> usize {
self.current_page + 1
}
/// Returns the active page layout mode.
pub fn layout_mode(&self) -> PageLayoutMode {
self.layout_mode
}
/// Sets the active page layout mode.
pub fn set_layout_mode(&mut self, layout_mode: PageLayoutMode) {
self.layout_mode = layout_mode;
}
/// Handles mouse down event for interactive dragging/panning.
pub fn handle_mouse_down(&mut self, position: (f32, f32)) {
self.last_mouse_pos = Some(position);
}
/// Handles mouse up event to end dragging.
pub fn handle_mouse_up(&mut self) {
self.last_mouse_pos = None;
}
/// Handles mouse move during active drag to update pan offsets.
pub fn handle_mouse_move(&mut self, position: (f32, f32)) {
if let Some(last) = self.last_mouse_pos {
let dx = position.0 - last.0;
let dy = position.1 - last.1;
self.pan_offset.0 += dx;
self.pan_offset.1 += dy;
self.last_mouse_pos = Some(position);
}
}
/// Returns true if a drag/pan operation is currently active.
pub fn is_dragging(&self) -> bool {
self.last_mouse_pos.is_some()
}
/// Returns the current zoom level as percentage integer (e.g. 100 for 1.0x).
pub fn zoom_percentage(&self) -> u32 {
(self.zoom_level * 100.0).round().max(1.0) as u32
}
/// Returns the active 0-indexed page index.
pub fn current_page(&self) -> usize {
self.current_page
}
/// Returns the current zoom level factor.
pub fn zoom_level(&self) -> f32 {
self.zoom_level
}
/// Returns the current pan offset (x, y).
pub fn pan_offset(&self) -> (f32, f32) {
self.pan_offset
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_VALID_PDF: &[u8] = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\nxref\n0 4\n0000000000 65535 f \n0000000010 00000 n \n0000000060 00000 n \n0000000117 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n185\n%%EOF\n";
#[test]
fn test_view_zoom_clamping() {
let mut view = PdfView::new(PdfViewerSettings::default());
view.set_zoom(0.01, None);
assert_eq!(view.zoom_level, MIN_ZOOM);
view.set_zoom(100.0, None);
assert_eq!(view.zoom_level, MAX_ZOOM);
}
#[test]
fn test_view_page_navigation() {
let mut view = PdfView::new(PdfViewerSettings::default());
view.load_from_bytes(SAMPLE_VALID_PDF.to_vec(), None)
.expect("Load");
assert_eq!(view.current_page, 0);
assert_eq!(view.display_page_number(), 1);
assert!(!view.previous_page());
assert!(!view.next_page()); // Only 1 page in dummy doc
}
#[test]
fn test_view_fit_to_width_calculation() {
let view = PdfView::new(PdfViewerSettings::default());
// Container width 832, page width 800 -> available 800 -> zoom = 1.0
let zoom = view.compute_fit_to_width_zoom(832.0, 800.0);
assert!((zoom - 1.0).abs() < 0.01);
}
#[test]
fn test_view_cache_and_render_cycle() {
let mut view = PdfView::new(PdfViewerSettings::default());
view.load_from_bytes(SAMPLE_VALID_PDF.to_vec(), None)
.expect("Load");
let page = view.get_or_render_page(0).expect("Render page");
assert!(page.width > 0);
assert_eq!(view.cache.len(), 1);
// Fetching again should hit cache
let cached = view.get_or_render_page(0).expect("Fetch cached");
assert_eq!(cached.width, page.width);
}
#[test]
fn test_view_mouse_drag_and_layout_mode() {
let mut view = PdfView::new(PdfViewerSettings::default());
assert_eq!(view.layout_mode(), PageLayoutMode::Continuous);
view.set_layout_mode(PageLayoutMode::SinglePage);
assert_eq!(view.layout_mode(), PageLayoutMode::SinglePage);
assert!(!view.is_dragging());
view.handle_mouse_down((100.0, 100.0));
assert!(view.is_dragging());
view.handle_mouse_move((120.0, 150.0));
assert_eq!(view.pan_offset, (20.0, 50.0));
view.handle_mouse_up();
assert!(!view.is_dragging());
}
}
+175
View File
@@ -0,0 +1,175 @@
//! File system change watcher and state-preserving auto-reload manager.
//!
//! Inspired by LaTeX-Workshop's continuous preview workflow:
//! When background compilers (e.g., `latexmk`, `pdflatex`, `typst watch`) rewrite
//! the target PDF, this module debounces file modification events and reloads
//! the document while strictly preserving:
//! 1. Current viewport scroll position & scroll percentage.
//! 2. Active zoom factor & layout mode.
//! 3. Active page index and selection states.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Snapshot of viewer position to restore seamlessly across document reloads.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewerStateSnapshot {
/// Active page index (0-indexed).
pub current_page: usize,
/// Vertical scroll offset as a percentage of total document height (0.0 to 1.0).
pub scroll_percent_y: f32,
/// Horizontal scroll offset as a percentage of document width (0.0 to 1.0).
pub scroll_percent_x: f32,
/// Exact zoom level at the moment of reload.
pub zoom_level: f32,
/// User pan offset in pixels.
pub pan_x: f32,
pub pan_y: f32,
}
impl Default for ViewerStateSnapshot {
fn default() -> Self {
Self {
current_page: 0,
scroll_percent_y: 0.0,
scroll_percent_x: 0.0,
zoom_level: 1.0,
pan_x: 0.0,
pan_y: 0.0,
}
}
}
/// Debounced change detector for watching PDF file modifications on disk.
#[derive(Debug)]
pub struct PdfReloadDebouncer {
file_path: PathBuf,
debounce_duration: Duration,
last_modified: Option<Instant>,
last_file_mtime: Option<std::time::SystemTime>,
is_reloading: Arc<AtomicBool>,
}
impl PdfReloadDebouncer {
/// Creates a new debouncer for the given file path.
pub fn new(file_path: PathBuf, debounce_ms: u64) -> Self {
let last_file_mtime = std::fs::metadata(&file_path)
.and_then(|m| m.modified())
.ok();
Self {
file_path,
debounce_duration: Duration::from_millis(debounce_ms),
last_modified: None,
last_file_mtime,
is_reloading: Arc::new(AtomicBool::new(false)),
}
}
/// Checks if the file has been modified on disk and if the debounce window has elapsed.
/// Returns `true` when it is safe and ready to reload the document.
pub fn should_reload(&mut self) -> bool {
let current_mtime = match std::fs::metadata(&self.file_path).and_then(|m| m.modified()) {
Ok(mtime) => mtime,
Err(_) => return false, // File might be temporarily locked or undergoing atomic replacement
};
let now = Instant::now();
if Some(current_mtime) != self.last_file_mtime {
// Modification detected! Start/update debounce timer
self.last_file_mtime = Some(current_mtime);
self.last_modified = Some(now);
return false;
}
if let Some(last_mod) = self.last_modified {
if now.duration_since(last_mod) >= self.debounce_duration {
// Debounce timer elapsed, file has settled
self.last_modified = None;
return true;
}
}
false
}
/// Resets the debouncer state after successful reload.
pub fn mark_reloaded(&mut self) {
self.last_modified = None;
if let Ok(mtime) = std::fs::metadata(&self.file_path).and_then(|m| m.modified()) {
self.last_file_mtime = Some(mtime);
}
}
/// Marks whether an async reload operation is currently underway.
pub fn set_reloading(&self, reloading: bool) {
self.is_reloading.store(reloading, Ordering::SeqCst);
}
/// True if an async reload is in progress.
pub fn is_reloading(&self) -> bool {
self.is_reloading.load(Ordering::SeqCst)
}
/// Returns the target file path.
pub fn path(&self) -> &Path {
&self.file_path
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_viewer_state_snapshot_defaults() {
let state = ViewerStateSnapshot::default();
assert_eq!(state.current_page, 0);
assert_eq!(state.zoom_level, 1.0);
assert_eq!(state.scroll_percent_y, 0.0);
}
#[test]
fn test_debouncer_detects_modification() {
let temp_dir = tempfile::tempdir().expect("Failed to create tempdir");
let pdf_path = temp_dir.path().join("test.pdf");
// Create initial file
{
let mut f = std::fs::File::create(&pdf_path).expect("Create file");
writeln!(f, "%PDF-1.7 initial").expect("Write");
}
let mut debouncer = PdfReloadDebouncer::new(pdf_path.clone(), 10);
assert!(!debouncer.should_reload(), "No modification yet");
// Small sleep to ensure mtime timestamp differs on fast filesystems
std::thread::sleep(Duration::from_millis(20));
// Modify file
{
let mut f = std::fs::File::create(&pdf_path).expect("Modify file");
writeln!(f, "%PDF-1.7 modified").expect("Write");
}
// First check detects mtime change and arms timer
let trigger1 = debouncer.should_reload();
assert!(!trigger1, "Should debounce immediately on change");
// Wait for debounce window
std::thread::sleep(Duration::from_millis(20));
let trigger2 = debouncer.should_reload();
assert!(trigger2, "Should trigger reload after debounce duration");
debouncer.mark_reloaded();
assert!(
!debouncer.should_reload(),
"Should not trigger again without change"
);
}
}
@@ -0,0 +1,107 @@
use pdf_viewer::{PageLayoutMode, PdfToolbarState, PdfView, PdfViewerSettings};
use std::time::Duration;
const MINIMAL_PDF: &[u8] = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\nxref\n0 4\n0000000000 65535 f \n0000000010 00000 n \n0000000060 00000 n \n0000000117 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n185\n%%EOF\n";
#[test]
fn test_end_to_end_pdf_view_lifecycle() {
let settings = PdfViewerSettings {
cache_budget_mb: 16,
..Default::default()
};
let mut view = PdfView::new(settings);
let sample_pdf = MINIMAL_PDF;
view.load_from_bytes(sample_pdf.to_vec(), None)
.expect("Load sample PDF bytes");
assert_eq!(view.total_pages(), 1);
assert_eq!(view.display_page_number(), 1);
// Initial container layout
view.set_container_size(1024.0, 768.0);
assert!(view.zoom_percentage() > 0);
// Zoom manipulations
view.set_zoom(1.5, Some((512.0, 384.0)));
assert_eq!(view.zoom_percentage(), 150);
view.zoom_in(None);
assert!(view.zoom_percentage() > 150);
view.reset_zoom();
assert_eq!(view.zoom_percentage(), 100);
// Rendering page
let rendered = view.get_or_render_page(0).expect("Render page 0");
assert!(rendered.width > 0);
assert!(rendered.height > 0);
assert!(rendered.dark_mode); // smart dark mode default
// Dark mode toggle
view.toggle_dark_mode();
let rendered_light = view.get_or_render_page(0).expect("Render light mode");
assert!(!rendered_light.dark_mode);
}
#[test]
fn test_reload_preserving_exact_viewport_and_zoom() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let pdf_path = temp_dir.path().join("document.pdf");
// Write initial PDF
{
std::fs::write(&pdf_path, MINIMAL_PDF).expect("write");
}
let mut view = PdfView::new(PdfViewerSettings::default());
view.open_file(&pdf_path).expect("open file");
// Simulate user zooming and panning
view.set_zoom(2.5, None);
view.handle_mouse_down((100.0, 100.0));
view.handle_mouse_move((150.0, 200.0));
view.handle_mouse_up();
let initial_zoom = view.zoom_percentage();
let snapshot = view.save_state_snapshot();
assert_eq!(initial_zoom, 250);
assert_eq!(snapshot.pan_x, 50.0);
assert_eq!(snapshot.pan_y, 100.0);
// Sleep to ensure timestamp difference
std::thread::sleep(Duration::from_millis(25));
// Compiler rebuilds the PDF
{
std::fs::write(&pdf_path, MINIMAL_PDF).expect("rebuild");
}
// Debounced hot reload trigger
let reloaded = view.reload_preserving_state().expect("hot reload");
assert!(reloaded, "Reload should succeed");
// Verify viewport state was strictly preserved
assert_eq!(view.zoom_percentage(), initial_zoom);
let new_snapshot = view.save_state_snapshot();
assert_eq!(new_snapshot.pan_x, 50.0);
assert_eq!(new_snapshot.pan_y, 100.0);
}
#[test]
fn test_toolbar_synchronization_with_view_state() {
let view = PdfView::new(PdfViewerSettings::default());
let toolbar_state = PdfToolbarState::new(
view.display_page_number(),
view.total_pages(),
view.zoom_percentage(),
view.dark_mode(),
view.layout_mode(),
);
assert_eq!(toolbar_state.current_page, 1);
assert_eq!(toolbar_state.zoom_percentage, 100);
assert!(toolbar_state.dark_mode);
assert_eq!(toolbar_state.layout_mode, PageLayoutMode::Continuous);
}
+1
View File
@@ -71,6 +71,7 @@ node_runtime.workspace = true
parking_lot.workspace = true
path.workspace = true
paths.workspace = true
pdf_viewer.workspace = true
percent-encoding.workspace = true
postage.workspace = true
prettier.workspace = true
+234 -14
View File
@@ -20,6 +20,12 @@ use std::sync::Arc;
use util::{ResultExt, rel_path::RelPath};
use worktree::{LoadedBinaryFile, PathChange, Worktree, WorktreeId};
const EMPTY_1X1_PNG: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 96, 96,
96, 96, 0, 0, 0, 5, 0, 1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq)]
pub struct ImageId(NonZeroU64);
@@ -107,6 +113,27 @@ pub struct ImageItem {
pub image: Arc<gpui::Image>,
reload_task: Option<Task<()>>,
pub image_metadata: Option<ImageMetadata>,
pub pdf_info: Option<PdfPageInfo>,
}
#[derive(Clone)]
pub struct PdfPageEntry {
pub page_index: usize,
pub width: u32,
pub height: u32,
pub image: Option<Arc<gpui::RenderImage>>,
pub links: Vec<pdf_viewer::PdfLinkAnnotation>,
pub text_segments: Vec<pdf_viewer::PdfTextSegment>,
pub page_text: String,
}
#[derive(Clone)]
pub struct PdfPageInfo {
pub current_page: usize,
pub total_pages: usize,
pub pdf_bytes: Arc<Vec<u8>>,
pub pages: Vec<PdfPageEntry>,
pub full_text: Option<String>,
}
#[derive(Clone, Eq, Hash, PartialEq)]
@@ -150,6 +177,20 @@ pub fn project_image_source(project: WeakEntity<Project>, path: ProjectPath) ->
impl ImageItem {
pub fn compute_metadata_from_bytes(image_bytes: &[u8]) -> Result<ImageMetadata> {
if image_bytes.starts_with(b"%PDF-") {
let engine = pdf_viewer::pdfium::PdfiumEngine::new();
let doc = engine.load_document_from_bytes(image_bytes, None)?;
let dim = doc.page_size(0).unwrap_or(pdf_viewer::PageDimensions::new(612.0, 792.0));
let (width, height) = dim.to_pixel_size(1.0, 144.0);
return Ok(ImageMetadata {
width,
height,
file_size: image_bytes.len() as u64,
format: image::ImageFormat::Png,
colors: ImageColorInfo::from_color_type(image::ColorType::Rgba8),
});
}
let image_format = image::guess_format(image_bytes)?;
let mut image_reader = ImageReader::new(std::io::Cursor::new(image_bytes));
@@ -220,23 +261,127 @@ impl ImageItem {
}
}
pub fn is_pdf(&self) -> bool {
self.pdf_info.is_some()
}
pub fn current_page(&self) -> usize {
self.pdf_info.as_ref().map(|p| p.current_page).unwrap_or(0)
}
pub fn total_pages(&self) -> usize {
self.pdf_info.as_ref().map(|p| p.total_pages).unwrap_or(1)
}
pub fn extract_text(&self) -> Option<String> {
let info = self.pdf_info.as_ref()?;
if let Some(ref text) = info.full_text {
Some(text.clone())
} else {
pdf_viewer::PdfiumEngine::new().extract_text_from_bytes(&info.pdf_bytes, None).ok()
}
}
pub fn set_page(&mut self, page_index: usize, cx: &mut Context<Self>) -> bool {
let Some(ref mut info) = self.pdf_info else {
return false;
};
if page_index >= info.total_pages || page_index == info.current_page {
return false;
}
info.current_page = page_index;
if let Some(page) = info.pages.get(page_index) {
self.image_metadata = Some(ImageMetadata {
width: page.width,
height: page.height,
file_size: info.pdf_bytes.len() as u64,
format: image::ImageFormat::Png,
colors: ImageColorInfo::from_color_type(image::ColorType::Rgba8),
});
cx.emit(ImageItemEvent::Reloaded);
cx.emit(ImageItemEvent::MetadataUpdated);
cx.notify();
true
} else {
false
}
}
pub fn next_page(&mut self, cx: &mut Context<Self>) -> bool {
let next = self.current_page() + 1;
self.set_page(next, cx)
}
pub fn previous_page(&mut self, cx: &mut Context<Self>) -> bool {
let cur = self.current_page();
if cur > 0 {
self.set_page(cur - 1, cx)
} else {
false
}
}
pub fn first_page(&mut self, cx: &mut Context<Self>) -> bool {
self.set_page(0, cx)
}
pub fn last_page(&mut self, cx: &mut Context<Self>) -> bool {
let last = self.total_pages().saturating_sub(1);
self.set_page(last, cx)
}
fn reload(&mut self, cx: &mut Context<Self>) -> Option<oneshot::Receiver<()>> {
let local_file = self.file.as_local()?;
let (tx, rx) = futures::channel::oneshot::channel();
let content = local_file.load_bytes(cx);
let background = cx.background_executor().clone();
self.reload_task = Some(cx.spawn(async move |this, cx| {
if let Some(image) = content
.await
.context("Failed to load image content")
.and_then(create_gpui_image)
.log_err()
{
this.update(cx, |this, cx| {
this.image = image;
cx.emit(ImageItemEvent::Reloaded);
})
.log_err();
if let Ok(bytes) = content.await.context("Failed to load image content") {
let is_pdf = bytes.starts_with(b"%PDF-");
let cur_page = this.read_with(cx, |this, _| this.current_page()).unwrap_or(0);
if is_pdf {
let bytes_clone = bytes.clone();
let pdf_res = background.spawn(async move {
create_gpui_images_from_pdf(&bytes_clone)
}).await;
if let Ok((pages, full_text)) = pdf_res {
let total_pages = pages.len();
let target_page = cur_page.min(total_pages.saturating_sub(1));
let (width, height) = pages
.get(target_page)
.map(|p| (p.width, p.height))
.unwrap_or((0, 0));
this.update(cx, |this, cx| {
this.pdf_info = Some(PdfPageInfo {
current_page: target_page,
total_pages,
pdf_bytes: Arc::new(bytes.clone()),
pages,
full_text,
});
this.image_metadata = Some(ImageMetadata {
width,
height,
file_size: bytes.len() as u64,
format: image::ImageFormat::Png,
colors: ImageColorInfo::from_color_type(image::ColorType::Rgba8),
});
cx.emit(ImageItemEvent::Reloaded);
cx.emit(ImageItemEvent::MetadataUpdated);
})
.log_err();
}
} else if let Some(image) = create_gpui_image(bytes).log_err() {
this.update(cx, |this, cx| {
this.image = image;
this.pdf_info = None;
cx.emit(ImageItemEvent::Reloaded);
})
.log_err();
}
}
_ = tx.send(());
}));
@@ -258,7 +403,7 @@ pub fn is_image_file(project: &Entity<Project>, path: &ProjectPath, cx: &App) ->
});
match ext {
Some(ext) => Img::extensions().contains(&ext.as_str()) && !ext.contains("svg"),
Some(ext) => (Img::extensions().contains(&ext.as_str()) || ext == "pdf") && !ext.contains("svg"),
None => false,
}
}
@@ -603,8 +748,26 @@ impl RemoteImageStore {
content.extend_from_slice(&chunk_data);
}
let is_pdf = content.starts_with(b"%PDF-");
let (image, pdf_info) = if is_pdf {
if let Ok((pages, full_text)) = create_gpui_images_from_pdf(&content) {
let total_pages = pages.len();
let pdf_info = PdfPageInfo {
current_page: 0,
total_pages,
pdf_bytes: Arc::new(content.clone()),
pages,
full_text,
};
let dummy_img = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, EMPTY_1X1_PNG.to_vec()));
(dummy_img, Some(pdf_info))
} else {
(create_gpui_image(content.clone())?, None)
}
} else {
(create_gpui_image(content.clone())?, None)
};
let image_metadata = ImageItem::compute_metadata_from_bytes(&content).log_err();
let image = create_gpui_image(content)?;
let proto_file = loading.state.file.context("missing file in image state")?;
let worktree_id = WorktreeId::from_proto(proto_file.worktree_id);
@@ -623,6 +786,7 @@ impl RemoteImageStore {
file,
image,
image_metadata,
pdf_info,
reload_task: None,
});
@@ -659,15 +823,39 @@ impl ImageStoreImpl for Entity<LocalImageStore> {
let load_file = worktree.update(cx, |worktree, cx| {
worktree.load_binary_file(path.as_ref(), cx)
});
let background = cx.background_executor().clone();
cx.spawn(async move |image_store, cx| {
let LoadedBinaryFile { file, content } = load_file.await?;
let image = create_gpui_image(content)?;
let is_pdf = content.starts_with(b"%PDF-");
let (image, pdf_info) = if is_pdf {
let content_clone = content.clone();
let pdf_res = background.spawn(async move {
create_gpui_images_from_pdf(&content_clone)
}).await;
if let Ok((pages, full_text)) = pdf_res {
let total_pages = pages.len();
let pdf_info = PdfPageInfo {
current_page: 0,
total_pages,
pdf_bytes: Arc::new(content.clone()),
pages,
full_text,
};
let dummy_img = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, EMPTY_1X1_PNG.to_vec()));
(dummy_img, Some(pdf_info))
} else {
(create_gpui_image(content.clone())?, None)
}
} else {
(create_gpui_image(content.clone())?, None)
};
let entity = cx.new(|cx| ImageItem {
id: cx.entity_id().as_non_zero_u64().into(),
file: file.clone(),
image,
image_metadata: None,
pdf_info,
reload_task: None,
});
@@ -931,6 +1119,38 @@ impl LocalImageStore {
}
}
pub fn create_gpui_images_from_pdf(
pdf_bytes: &[u8],
) -> anyhow::Result<(Vec<PdfPageEntry>, Option<String>)> {
let engine = pdf_viewer::PdfiumEngine::new();
let details = engine.extract_document_details(pdf_bytes)?;
let mut pages = Vec::with_capacity(details.pages.len());
for page in details.pages {
pages.push(PdfPageEntry {
page_index: page.page_index,
width: (page.width_pt.round() as u32).max(1),
height: (page.height_pt.round() as u32).max(1),
image: None,
links: page.links,
text_segments: page.text_segments,
page_text: page.text,
});
}
if pages.is_empty() {
anyhow::bail!("Failed to parse PDF document: no pages found or Pdfium engine unavailable");
}
let full_text = if !details.full_text.is_empty() {
Some(details.full_text)
} else {
None
};
Ok((pages, full_text))
}
fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
let format = image::guess_format(&content)?;