Merge remote-tracking branch 'kk376/feat/pdf-viewer' into pdf-viewer
# Conflicts: # README.md
This commit is contained in:
commit
617a8d3814
23 files changed
+4596
-70
No files matched your search
Generated
+77
@@ -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"
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
> [!IMPORTANT]
|
||||
> Remove this line to confirm you've reviewed this PR before submitting.
|
||||
|
||||
# Zed
|
||||
|
||||
[](https://zed.dev)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
Reference in new issue
Block a user