From 9a147bbba7d6781f84453630441ad3126813bb7c Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 07:21:36 +0530 Subject: [PATCH 01/19] feat(project): wire kkpdf-zed native PDF viewing into Zed image opener --- Cargo.lock | 100 +++++++++++++++++++++++++++++- Cargo.toml | 1 + crates/project/Cargo.toml | 1 + crates/project/src/image_store.rs | 29 ++++++++- 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7db15b1c29..de5e5175fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3692,6 +3692,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" @@ -9434,6 +9444,19 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kkpdf-zed" +version = "0.1.0" +dependencies = [ + "anyhow", + "image", + "log", + "parking_lot", + "pdfium-render", + "schemars 0.8.22", + "serde", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -12714,6 +12737,32 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0008e816fcdaf229cdd540e9b6ca2dc4a10d65c31624abb546c6420a02846e61" +[[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" @@ -13449,6 +13498,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" @@ -13901,6 +13956,7 @@ dependencies = [ "image", "indexmap 2.14.0", "itertools 0.14.0", + "kkpdf-zed", "language", "log", "lsp", @@ -15884,6 +15940,18 @@ dependencies = [ "theme_settings", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -15905,11 +15973,23 @@ dependencies = [ "dyn-clone", "indexmap 2.14.0", "ref-cast", - "schemars_derive", + "schemars_derive 1.0.4", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "schemars_derive" version = "1.0.4" @@ -19681,6 +19761,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" @@ -19835,6 +19924,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" diff --git a/Cargo.toml b/Cargo.toml index 6e4907333e..e19280484f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -376,6 +376,7 @@ http_client_tls = { path = "crates/http_client_tls" } http_proxy = { path = "crates/http_proxy" } icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } +kkpdf-zed = { path = "../../kkpdf-zed" } input_latency_ui = { path = "crates/input_latency_ui" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 937a123b15..2a1e6af097 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -61,6 +61,7 @@ globset.workspace = true gpui.workspace = true http_client = { workspace = true, features = ["github-download"] } image.workspace = true +kkpdf-zed.workspace = true indexmap.workspace = true itertools.workspace = true language.workspace = true diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index a667f21788..a5920c9fd4 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -258,7 +258,7 @@ pub fn is_image_file(project: &Entity, 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, } } @@ -932,6 +932,33 @@ impl LocalImageStore { } fn create_gpui_image(content: Vec) -> anyhow::Result> { + if content.starts_with(b"%PDF-") { + let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + let options = kkpdf_zed::rasterizer::RasterizerOptions { + target_dpi: 144.0, + zoom_factor: 1.0, + dark_mode: false, + saturation_threshold: 0.18, + }; + let arc_bytes = Arc::new(content); + let rendered_page = engine.render_page_from_bytes(&arc_bytes, 0, options)?; + + let mut png_bytes: Vec = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut png_bytes); + let img_buf = image::ImageBuffer::, _>::from_raw( + rendered_page.width, + rendered_page.height, + rendered_page.rgba_buffer.as_ref().clone(), + ) + .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; + + img_buf.write_to(&mut cursor, image::ImageFormat::Png)?; + return Ok(Arc::new(gpui::Image::from_bytes( + gpui::ImageFormat::Png, + png_bytes, + ))); + } + let format = image::guess_format(&content)?; Ok(Arc::new(gpui::Image::from_bytes( From 5d023795318da25a68d2ba63c3b060fc6367f2ce Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 07:56:41 +0530 Subject: [PATCH 02/19] fix(project): support PDF metadata decoding in compute_metadata_from_bytes --- crates/project/src/image_store.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index a5920c9fd4..00cea8fd38 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -150,6 +150,25 @@ pub fn project_image_source(project: WeakEntity, path: ProjectPath) -> impl ImageItem { pub fn compute_metadata_from_bytes(image_bytes: &[u8]) -> Result { + if image_bytes.starts_with(b"%PDF-") { + let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + let options = kkpdf_zed::rasterizer::RasterizerOptions { + target_dpi: 144.0, + zoom_factor: 1.0, + dark_mode: false, + saturation_threshold: 0.18, + }; + let arc_bytes = Arc::new(image_bytes.to_vec()); + let rendered_page = engine.render_page_from_bytes(&arc_bytes, 0, options)?; + return Ok(ImageMetadata { + width: rendered_page.width, + height: rendered_page.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)); From 6517257c496ec1d031ef9c5dc3ef927ea583c2c7 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 08:06:28 +0530 Subject: [PATCH 03/19] feat(pdf): integrate continuous multi-page PDF rendering in image_store --- crates/project/src/image_store.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 00cea8fd38..1abb1544bf 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -152,17 +152,11 @@ impl ImageItem { pub fn compute_metadata_from_bytes(image_bytes: &[u8]) -> Result { if image_bytes.starts_with(b"%PDF-") { let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); - let options = kkpdf_zed::rasterizer::RasterizerOptions { - target_dpi: 144.0, - zoom_factor: 1.0, - dark_mode: false, - saturation_threshold: 0.18, - }; - let arc_bytes = Arc::new(image_bytes.to_vec()); - let rendered_page = engine.render_page_from_bytes(&arc_bytes, 0, options)?; + let doc = engine.load_document_from_bytes(image_bytes, None)?; + let (width, height) = doc.total_pixel_size(1.0, 144.0, 20); return Ok(ImageMetadata { - width: rendered_page.width, - height: rendered_page.height, + width, + height, file_size: image_bytes.len() as u64, format: image::ImageFormat::Png, colors: ImageColorInfo::from_color_type(image::ColorType::Rgba8), @@ -960,14 +954,14 @@ fn create_gpui_image(content: Vec) -> anyhow::Result> { saturation_threshold: 0.18, }; let arc_bytes = Arc::new(content); - let rendered_page = engine.render_page_from_bytes(&arc_bytes, 0, options)?; + let rendered_doc = engine.render_document_from_bytes(&arc_bytes, options, 20)?; let mut png_bytes: Vec = Vec::new(); let mut cursor = std::io::Cursor::new(&mut png_bytes); let img_buf = image::ImageBuffer::, _>::from_raw( - rendered_page.width, - rendered_page.height, - rendered_page.rgba_buffer.as_ref().clone(), + rendered_doc.width, + rendered_doc.height, + rendered_doc.rgba_buffer.as_ref().clone(), ) .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; From b8b3ea32d1b71d7345128333b62669ad42da54f8 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 08:27:58 +0530 Subject: [PATCH 04/19] feat(image_viewer): add multi-page PDF navigation and fix GPU driver crash - Render PDF pages on-demand per active page instead of stitching entire document into one giant texture (fixing 22k px Vulkan texture atlas overflow and driver crash). - Add toolbar navigation buttons (previous, next, page indicator). - Add keyboard shortcuts (PageUp/PageDown, Left/Right arrows, j/k, Home/End) for page navigation. - Add status bar metadata for active PDF page index and total page count. --- assets/keymaps/default-linux.json | 8 + assets/keymaps/default-macos.json | 8 + assets/keymaps/default-windows.json | 8 + crates/image_viewer/src/image_info.rs | 54 ++++-- crates/image_viewer/src/image_viewer.rs | 84 +++++++++- crates/project/src/image_store.rs | 208 ++++++++++++++++++++---- 6 files changed, 316 insertions(+), 54 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index db670d2655..dd192204c4 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1582,6 +1582,14 @@ "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" }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 3324bce7da..28e3673476 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1629,6 +1629,14 @@ "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" }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1afd97082a..86fd754e21 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1558,6 +1558,14 @@ "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" }, }, { diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs index c819970051..0ded6d3c40 100644 --- a/crates/image_viewer/src/image_info.rs +++ b/crates/image_viewer/src/image_info.rs @@ -9,6 +9,7 @@ use crate::{ImageFileSizeUnit, ImageView, ImageViewerSettings}; pub struct ImageInfo { metadata: Option, + pdf_info: Option<(usize, usize)>, _observe_active_image: Option, observe_image_item: Option, } @@ -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, cx: &mut Context) { 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(), ); diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 426430619f..2f938f81ae 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -24,7 +24,7 @@ use project::{ }; use settings::Settings; use theme_settings::ThemeSettings; -use ui::{Tooltip, prelude::*}; +use ui::{Divider, Tooltip, prelude::*}; use util::{ResultExt as _, paths::PathExt}; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -48,7 +48,15 @@ actions!( /// Fit the image to view. FitToView, /// Zoom to actual size (100%). - ZoomToActualSize + ZoomToActualSize, + /// Go to next page (PDF). + NextPage, + /// Go to previous page (PDF). + PreviousPage, + /// Go to first page (PDF). + FirstPage, + /// Go to last page (PDF). + LastPage ] ); @@ -195,6 +203,8 @@ impl ImageView { ImageItemEvent::MetadataUpdated | ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => { + let image = self.image_item.read(cx).image.clone(); + self.pending_image = Some(image); self.image_size = self .image_item .read(cx) @@ -249,6 +259,30 @@ impl ImageView { cx.notify(); } + fn next_page(&mut self, _: &NextPage, _window: &mut Window, cx: &mut Context) { + self.image_item.update(cx, |item, cx| { + item.next_page(cx); + }); + } + + fn previous_page(&mut self, _: &PreviousPage, _window: &mut Window, cx: &mut Context) { + self.image_item.update(cx, |item, cx| { + item.previous_page(cx); + }); + } + + fn first_page(&mut self, _: &FirstPage, _window: &mut Window, cx: &mut Context) { + self.image_item.update(cx, |item, cx| { + item.first_page(cx); + }); + } + + fn last_page(&mut self, _: &LastPage, _window: &mut Window, cx: &mut Context) { + self.image_item.update(cx, |item, cx| { + item.last_page(cx); + }); + } + fn reveal_in_file_manager( &mut self, _: &RevealInFileManager, @@ -774,6 +808,10 @@ impl Render for ImageView { .on_action(cx.listener(Self::fit_to_view)) .on_action(cx.listener(Self::zoom_to_actual_size)) .on_action(cx.listener(Self::reveal_in_file_manager)) + .on_action(cx.listener(Self::next_page)) + .on_action(cx.listener(Self::previous_page)) + .on_action(cx.listener(Self::first_page)) + .on_action(cx.listener(Self::last_page)) .size_full() .relative() .bg(cx.theme().colors().editor_background) @@ -939,8 +977,50 @@ impl Render for ImageViewToolbarControls { return div().into_any_element(); }; + let image_item = image_view.read(cx).image_item.clone(); + let (is_pdf, current_page, total_pages) = { + let item = image_item.read(cx); + (item.is_pdf(), item.current_page(), item.total_pages()) + }; + h_flex() .gap_1() + .when(is_pdf && total_pages > 1, |this| { + let image_view_prev = image_view.downgrade(); + let image_view_next = image_view.downgrade(); + this.child( + IconButton::new("prev-page", IconName::ChevronLeft) + .icon_size(IconSize::Small) + .disabled(current_page == 0) + .tooltip(|_window, cx| Tooltip::for_action("Previous Page", &PreviousPage, cx)) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_prev.upgrade() { + view.update(cx, |this, cx| { + this.previous_page(&PreviousPage, window, cx); + }); + } + }), + ) + .child( + h_flex() + .px_1() + .child(Label::new(format!("{} / {}", current_page + 1, total_pages)).size(LabelSize::Small)) + ) + .child( + IconButton::new("next-page", IconName::ChevronRight) + .icon_size(IconSize::Small) + .disabled(current_page + 1 >= total_pages) + .tooltip(|_window, cx| Tooltip::for_action("Next Page", &NextPage, cx)) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_next.upgrade() { + view.update(cx, |this, cx| { + this.next_page(&NextPage, window, cx); + }); + } + }), + ) + .child(Divider::vertical()) + }) .child( IconButton::new("zoom-out", IconName::Dash) .icon_size(IconSize::Small) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 1abb1544bf..7cb4d04de6 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -107,6 +107,14 @@ pub struct ImageItem { pub image: Arc, reload_task: Option>, pub image_metadata: Option, + pub pdf_info: Option, +} + +#[derive(Clone)] +pub struct PdfPageInfo { + pub current_page: usize, + pub total_pages: usize, + pub pdf_bytes: Arc>, } #[derive(Clone, Eq, Hash, PartialEq)] @@ -153,7 +161,8 @@ impl ImageItem { if image_bytes.starts_with(b"%PDF-") { let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); let doc = engine.load_document_from_bytes(image_bytes, None)?; - let (width, height) = doc.total_pixel_size(1.0, 144.0, 20); + let dim = doc.page_size(0).unwrap_or(kkpdf_zed::PageDimensions::new(612.0, 792.0)); + let (width, height) = dim.to_pixel_size(1.0, 144.0); return Ok(ImageMetadata { width, height, @@ -233,23 +242,117 @@ 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 set_page(&mut self, page_index: usize, cx: &mut Context) -> 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; + } + + match create_gpui_image_from_pdf_page(&info.pdf_bytes, page_index) { + Ok((new_image, width, height)) => { + info.current_page = page_index; + self.image = new_image; + self.image_metadata = Some(ImageMetadata { + width, + 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 + } + Err(e) => { + log::error!("Failed to render PDF page {}: {:#}", page_index, e); + false + } + } + } + + pub fn next_page(&mut self, cx: &mut Context) -> bool { + let next = self.current_page() + 1; + self.set_page(next, cx) + } + + pub fn previous_page(&mut self, cx: &mut Context) -> 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) -> bool { + self.set_page(0, cx) + } + + pub fn last_page(&mut self, cx: &mut Context) -> bool { + let last = self.total_pages().saturating_sub(1); + self.set_page(last, cx) + } + fn reload(&mut self, cx: &mut Context) -> Option> { let local_file = self.file.as_local()?; let (tx, rx) = futures::channel::oneshot::channel(); let content = local_file.load_bytes(cx); 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 { + if let Ok(engine) = anyhow::Ok(kkpdf_zed::pdfium::PdfiumEngine::new()) { + if let Ok(doc) = engine.load_document_from_bytes(&bytes, None) { + let total_pages = doc.total_pages(); + let target_page = cur_page.min(total_pages.saturating_sub(1)); + if let Ok((img, width, height)) = create_gpui_image_from_pdf_page(&bytes, target_page) { + this.update(cx, |this, cx| { + this.image = img; + this.pdf_info = Some(PdfPageInfo { + current_page: target_page, + total_pages, + pdf_bytes: Arc::new(bytes.clone()), + }); + 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(()); })); @@ -616,6 +719,18 @@ impl RemoteImageStore { content.extend_from_slice(&chunk_data); } + let is_pdf = content.starts_with(b"%PDF-"); + let pdf_info = if is_pdf { + let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + let doc = engine.load_document_from_bytes(&content, None).log_err(); + doc.map(|doc| PdfPageInfo { + current_page: 0, + total_pages: doc.total_pages(), + pdf_bytes: Arc::new(content.clone()), + }) + } else { + None + }; let image_metadata = ImageItem::compute_metadata_from_bytes(&content).log_err(); let image = create_gpui_image(content)?; @@ -636,6 +751,7 @@ impl RemoteImageStore { file, image, image_metadata, + pdf_info, reload_task: None, }); @@ -674,6 +790,18 @@ impl ImageStoreImpl for Entity { }); cx.spawn(async move |image_store, cx| { let LoadedBinaryFile { file, content } = load_file.await?; + let is_pdf = content.starts_with(b"%PDF-"); + let pdf_info = if is_pdf { + let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + let doc = engine.load_document_from_bytes(&content, None).log_err(); + doc.map(|doc| PdfPageInfo { + current_page: 0, + total_pages: doc.total_pages(), + pdf_bytes: Arc::new(content.clone()), + }) + } else { + None + }; let image = create_gpui_image(content)?; let entity = cx.new(|cx| ImageItem { @@ -681,6 +809,7 @@ impl ImageStoreImpl for Entity { file: file.clone(), image, image_metadata: None, + pdf_info, reload_task: None, }); @@ -944,32 +1073,43 @@ impl LocalImageStore { } } -fn create_gpui_image(content: Vec) -> anyhow::Result> { - if content.starts_with(b"%PDF-") { - let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); - let options = kkpdf_zed::rasterizer::RasterizerOptions { - target_dpi: 144.0, - zoom_factor: 1.0, - dark_mode: false, - saturation_threshold: 0.18, - }; - let arc_bytes = Arc::new(content); - let rendered_doc = engine.render_document_from_bytes(&arc_bytes, options, 20)?; +pub fn create_gpui_image_from_pdf_page( + pdf_bytes: &[u8], + page_index: usize, +) -> anyhow::Result<(Arc, u32, u32)> { + let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + let options = kkpdf_zed::rasterizer::RasterizerOptions { + target_dpi: 144.0, + zoom_factor: 1.0, + dark_mode: false, + saturation_threshold: 0.18, + }; + let page = engine.render_page_from_bytes(pdf_bytes, page_index, options)?; - let mut png_bytes: Vec = Vec::new(); - let mut cursor = std::io::Cursor::new(&mut png_bytes); - let img_buf = image::ImageBuffer::, _>::from_raw( - rendered_doc.width, - rendered_doc.height, - rendered_doc.rgba_buffer.as_ref().clone(), - ) - .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; + let mut png_bytes: Vec = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut png_bytes); + let img_buf = image::ImageBuffer::, _>::from_raw( + page.width, + page.height, + page.rgba_buffer.as_ref().clone(), + ) + .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; - img_buf.write_to(&mut cursor, image::ImageFormat::Png)?; - return Ok(Arc::new(gpui::Image::from_bytes( + img_buf.write_to(&mut cursor, image::ImageFormat::Png)?; + Ok(( + Arc::new(gpui::Image::from_bytes( gpui::ImageFormat::Png, png_bytes, - ))); + )), + page.width, + page.height, + )) +} + +fn create_gpui_image(content: Vec) -> anyhow::Result> { + if content.starts_with(b"%PDF-") { + let (img, _, _) = create_gpui_image_from_pdf_page(&content, 0)?; + return Ok(img); } let format = image::guess_format(&content)?; From 5f81d1a45ebcd1e72c00274a8bbaf7b0da5ac852 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 08:48:32 +0530 Subject: [PATCH 05/19] feat(pdf): continuous vertical scroll stack, 100% default zoom, and text copy --- assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 4 +- assets/keymaps/default-windows.json | 4 +- crates/image_viewer/src/image_viewer.rs | 245 +++++++++++++++++++----- crates/project/src/image_store.rs | 212 +++++++++++++------- 5 files changed, 358 insertions(+), 111 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index dd192204c4..afb5df0fd1 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1589,7 +1589,9 @@ "pageup": "image_viewer::PreviousPage", "k": "image_viewer::PreviousPage", "home": "image_viewer::FirstPage", - "end": "image_viewer::LastPage" + "end": "image_viewer::LastPage", + "ctrl-c": "image_viewer::CopyText", + "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 28e3673476..fa013345f7 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1636,7 +1636,9 @@ "pageup": "image_viewer::PreviousPage", "k": "image_viewer::PreviousPage", "home": "image_viewer::FirstPage", - "end": "image_viewer::LastPage" + "end": "image_viewer::LastPage", + "cmd-c": "image_viewer::CopyText", + "cmd-shift-t": "image_viewer::ToggleTextPanel" }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 86fd754e21..4933696332 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1565,7 +1565,9 @@ "pageup": "image_viewer::PreviousPage", "k": "image_viewer::PreviousPage", "home": "image_viewer::FirstPage", - "end": "image_viewer::LastPage" + "end": "image_viewer::LastPage", + "ctrl-c": "image_viewer::CopyText", + "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, { diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 2f938f81ae..c3369b12af 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -56,7 +56,11 @@ actions!( /// Go to first page (PDF). FirstPage, /// Go to last page (PDF). - LastPage + LastPage, + /// Copy text from PDF. + CopyText, + /// Toggle text inspection panel. + ToggleTextPanel ] ); @@ -81,6 +85,10 @@ pub struct ImageView { image_size: Option<(u32, u32)>, pending_image: Option>, displayed_image: Option, + pdf_scroll_handle: gpui::ScrollHandle, + show_text_panel: bool, + text_buffer: Option>, + text_editor: Option>, } struct DisplayedImage { @@ -190,6 +198,10 @@ impl ImageView { image_size, pending_image: Some(pending_image), displayed_image: None, + pdf_scroll_handle: gpui::ScrollHandle::new(), + show_text_panel: false, + text_buffer: None, + text_editor: None, } } @@ -260,27 +272,71 @@ impl ImageView { } fn next_page(&mut self, _: &NextPage, _window: &mut Window, cx: &mut Context) { - self.image_item.update(cx, |item, cx| { - item.next_page(cx); + let changed = self.image_item.update(cx, |item, cx| { + item.next_page(cx) }); + if changed { + let cur = self.image_item.read(cx).current_page(); + self.pdf_scroll_handle.scroll_to_top_of_item(cur); + cx.notify(); + } } fn previous_page(&mut self, _: &PreviousPage, _window: &mut Window, cx: &mut Context) { - self.image_item.update(cx, |item, cx| { - item.previous_page(cx); + let changed = self.image_item.update(cx, |item, cx| { + item.previous_page(cx) }); + if changed { + let cur = self.image_item.read(cx).current_page(); + self.pdf_scroll_handle.scroll_to_top_of_item(cur); + cx.notify(); + } } fn first_page(&mut self, _: &FirstPage, _window: &mut Window, cx: &mut Context) { - self.image_item.update(cx, |item, cx| { - item.first_page(cx); + let changed = self.image_item.update(cx, |item, cx| { + item.first_page(cx) }); + if changed { + self.pdf_scroll_handle.scroll_to_top_of_item(0); + cx.notify(); + } } fn last_page(&mut self, _: &LastPage, _window: &mut Window, cx: &mut Context) { - self.image_item.update(cx, |item, cx| { - item.last_page(cx); + let changed = self.image_item.update(cx, |item, cx| { + item.last_page(cx) }); + if changed { + let cur = self.image_item.read(cx).current_page(); + self.pdf_scroll_handle.scroll_to_top_of_item(cur); + cx.notify(); + } + } + + fn copy_text(&mut self, _: &CopyText, _window: &mut Window, cx: &mut Context) { + if let Some(text) = self.image_item.read(cx).extract_text() { + if !text.is_empty() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + } + } + } + + fn toggle_text_panel(&mut self, _: &ToggleTextPanel, window: &mut Window, cx: &mut Context) { + self.show_text_panel = !self.show_text_panel; + if self.show_text_panel && self.text_editor.is_none() { + if let Some(text) = self.image_item.read(cx).extract_text() { + let buffer = cx.new(|cx| language::Buffer::local(text, cx)); + let editor = cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer.clone(), Some(self.project.clone()), window, cx); + editor.set_read_only(true); + editor + }); + self.text_buffer = Some(buffer); + self.text_editor = Some(editor); + } + } + cx.notify(); } fn reveal_in_file_manager( @@ -453,17 +509,7 @@ impl Element for ImageContentElement { let image_view = self.image_view.read(cx); let image = image_view.image_item.read(cx).image.clone(); - let first_layout = image_view.container_bounds.is_none(); - - let initial_zoom_level = first_layout - .then(|| { - image_view - .image_size - .map(|image_size| ImageView::compute_fit_to_view_zoom(bounds, image_size)) - }) - .flatten(); - - let zoom_level = initial_zoom_level.unwrap_or(image_view.zoom_level); + let zoom_level = image_view.zoom_level; let pan_offset = image_view.pan_offset; let border_color = cx.theme().colors().border; @@ -493,9 +539,6 @@ impl Element for ImageContentElement { let render_image = image.clone().use_render_image(window, cx); this.update_displayed_image(&image, render_image, window, cx); this.container_bounds = Some(bounds); - if let Some(initial_zoom_level) = initial_zoom_level { - this.zoom_level = initial_zoom_level; - } }); let mut image_content = div() @@ -692,6 +735,10 @@ impl Item for ImageView { image_size: self.image_size, pending_image: None, displayed_image: None, + pdf_scroll_handle: gpui::ScrollHandle::new(), + show_text_panel: self.show_text_panel, + text_buffer: None, + text_editor: None, }))) } @@ -799,6 +846,8 @@ impl Focusable for ImageView { impl Render for ImageView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + div() .track_focus(&self.focus_handle(cx)) .key_context("ImageViewer") @@ -812,29 +861,108 @@ impl Render for ImageView { .on_action(cx.listener(Self::previous_page)) .on_action(cx.listener(Self::first_page)) .on_action(cx.listener(Self::last_page)) + .on_action(cx.listener(Self::copy_text)) + .on_action(cx.listener(Self::toggle_text_panel)) .size_full() .relative() .bg(cx.theme().colors().editor_background) .child({ - let container = div() - .id("image-container") - .size_full() - .overflow_hidden() - .cursor(if self.is_dragging() { - gpui::CursorStyle::ClosedHand - } else { - gpui::CursorStyle::OpenHand - }) - .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) - .on_pinch(cx.listener(Self::handle_pinch)) - .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) - .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) - .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) - .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) - .on_mouse_move(cx.listener(Self::handle_mouse_move)) - .child(ImageContentElement::new(cx.entity())); + if let Some(pdf_info) = pdf_info.filter(|info| !info.pages.is_empty()) { + let border_color = cx.theme().colors().border; + let zoom_level = self.zoom_level; + let total_pages = pdf_info.total_pages; - container + let main_pdf_view = div() + .id("pdf-scroll-container") + .track_scroll(&self.pdf_scroll_handle) + .overflow_y_scroll() + .size_full() + .child( + v_flex() + .w_full() + .items_center() + .py_6() + .gap_8() + .children(pdf_info.pages.into_iter().map(|page| { + let page_w = px(page.width as f32 * zoom_level); + let page_h = px(page.height as f32 * zoom_level); + let page_idx = page.page_index; + v_flex() + .id(("pdf-page-container", page_idx)) + .items_center() + .gap_2() + .child( + div() + .id(("pdf-page-canvas", page_idx)) + .w(page_w) + .h(page_h) + .shadow_md() + .border_1() + .border_color(border_color) + .bg(gpui::white()) + .child( + img(page.image) + .id(("pdf-page-img", page_idx)) + .size_full() + ) + ) + .child( + Label::new(format!("Page {} of {}", page_idx + 1, total_pages)) + .size(LabelSize::XSmall) + .color(Color::Muted) + ) + })) + ); + + if self.show_text_panel { + h_flex() + .size_full() + .child( + div() + .flex_1() + .h_full() + .child(main_pdf_view) + ) + .child(Divider::vertical()) + .child( + div() + .w_96() + .h_full() + .p_2() + .bg(cx.theme().colors().panel_background) + .child( + if let Some(editor) = &self.text_editor { + div().size_full().child(editor.clone()).into_any_element() + } else { + div().size_full().child(Label::new("Extracting text...")).into_any_element() + } + ) + ) + .into_any_element() + } else { + main_pdf_view.into_any_element() + } + } else { + let container = div() + .id("image-container") + .size_full() + .overflow_hidden() + .cursor(if self.is_dragging() { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_pinch(cx.listener(Self::handle_pinch)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) + .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) + .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) + .on_mouse_move(cx.listener(Self::handle_mouse_move)) + .child(ImageContentElement::new(cx.entity())); + + container.into_any_element() + } }) } } @@ -978,9 +1106,10 @@ impl Render for ImageViewToolbarControls { }; let image_item = image_view.read(cx).image_item.clone(); - let (is_pdf, current_page, total_pages) = { + let (is_pdf, current_page, total_pages, show_text_panel) = { let item = image_item.read(cx); - (item.is_pdf(), item.current_page(), item.total_pages()) + let show_text = image_view.read(cx).show_text_panel; + (item.is_pdf(), item.current_page(), item.total_pages(), show_text) }; h_flex() @@ -1021,6 +1150,36 @@ impl Render for ImageViewToolbarControls { ) .child(Divider::vertical()) }) + .when(is_pdf, |this| { + let image_view_copy = image_view.downgrade(); + let image_view_text = image_view.downgrade(); + this.child( + IconButton::new("copy-text", IconName::Copy) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| Tooltip::for_action("Copy Text", &CopyText, cx)) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_copy.upgrade() { + view.update(cx, |this, cx| { + this.copy_text(&CopyText, window, cx); + }); + } + }), + ) + .child( + IconButton::new("toggle-text-panel", IconName::FileDoc) + .icon_size(IconSize::Small) + .toggle_state(show_text_panel) + .tooltip(|_window, cx| Tooltip::for_action("Toggle Text Panel", &ToggleTextPanel, cx)) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_text.upgrade() { + view.update(cx, |this, cx| { + this.toggle_text_panel(&ToggleTextPanel, window, cx); + }); + } + }), + ) + .child(Divider::vertical()) + }) .child( IconButton::new("zoom-out", IconName::Dash) .icon_size(IconSize::Small) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 7cb4d04de6..d780669720 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -110,11 +110,21 @@ pub struct ImageItem { pub pdf_info: Option, } +#[derive(Clone)] +pub struct PdfPageEntry { + pub page_index: usize, + pub width: u32, + pub height: u32, + pub image: Arc, +} + #[derive(Clone)] pub struct PdfPageInfo { pub current_page: usize, pub total_pages: usize, pub pdf_bytes: Arc>, + pub pages: Vec, + pub full_text: Option, } #[derive(Clone, Eq, Hash, PartialEq)] @@ -254,6 +264,15 @@ impl ImageItem { self.pdf_info.as_ref().map(|p| p.total_pages).unwrap_or(1) } + pub fn extract_text(&self) -> Option { + let info = self.pdf_info.as_ref()?; + if let Some(ref text) = info.full_text { + Some(text.clone()) + } else { + kkpdf_zed::PdfiumEngine::new().extract_text_from_bytes(&info.pdf_bytes, None).ok() + } + } + pub fn set_page(&mut self, page_index: usize, cx: &mut Context) -> bool { let Some(ref mut info) = self.pdf_info else { return false; @@ -262,26 +281,22 @@ impl ImageItem { return false; } - match create_gpui_image_from_pdf_page(&info.pdf_bytes, page_index) { - Ok((new_image, width, height)) => { - info.current_page = page_index; - self.image = new_image; - self.image_metadata = Some(ImageMetadata { - width, - 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 - } - Err(e) => { - log::error!("Failed to render PDF page {}: {:#}", page_index, e); - false - } + info.current_page = page_index; + if let Some(page) = info.pages.get(page_index) { + self.image = page.image.clone(); + 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 } } @@ -319,31 +334,37 @@ impl ImageItem { let cur_page = this.read_with(cx, |this, _| this.current_page()).unwrap_or(0); if is_pdf { - if let Ok(engine) = anyhow::Ok(kkpdf_zed::pdfium::PdfiumEngine::new()) { - if let Ok(doc) = engine.load_document_from_bytes(&bytes, None) { - let total_pages = doc.total_pages(); - let target_page = cur_page.min(total_pages.saturating_sub(1)); - if let Ok((img, width, height)) = create_gpui_image_from_pdf_page(&bytes, target_page) { - this.update(cx, |this, cx| { - this.image = img; - this.pdf_info = Some(PdfPageInfo { - current_page: target_page, - total_pages, - pdf_bytes: Arc::new(bytes.clone()), - }); - 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(); - } - } + if let Ok((pages, full_text)) = create_gpui_images_from_pdf(&bytes) { + let total_pages = pages.len(); + let target_page = cur_page.min(total_pages.saturating_sub(1)); + let first_image = pages + .get(target_page) + .map(|p| p.image.clone()) + .unwrap_or_else(|| create_gpui_image(bytes.clone()).unwrap()); + let (width, height) = pages + .get(target_page) + .map(|p| (p.width, p.height)) + .unwrap_or((0, 0)); + this.update(cx, |this, cx| { + this.image = first_image; + 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| { @@ -720,19 +741,28 @@ impl RemoteImageStore { } let is_pdf = content.starts_with(b"%PDF-"); - let pdf_info = if is_pdf { - let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); - let doc = engine.load_document_from_bytes(&content, None).log_err(); - doc.map(|doc| PdfPageInfo { - current_page: 0, - total_pages: doc.total_pages(), - pdf_bytes: Arc::new(content.clone()), - }) + let (image, pdf_info) = if is_pdf { + if let Ok((pages, full_text)) = create_gpui_images_from_pdf(&content) { + let first_image = pages + .first() + .map(|p| p.image.clone()) + .unwrap_or_else(|| create_gpui_image(content.clone()).unwrap()); + let total_pages = pages.len(); + let pdf_info = PdfPageInfo { + current_page: 0, + total_pages, + pdf_bytes: Arc::new(content.clone()), + pages, + full_text, + }; + (first_image, Some(pdf_info)) + } else { + (create_gpui_image(content.clone())?, None) + } } else { - None + (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); @@ -791,18 +821,27 @@ impl ImageStoreImpl for Entity { cx.spawn(async move |image_store, cx| { let LoadedBinaryFile { file, content } = load_file.await?; let is_pdf = content.starts_with(b"%PDF-"); - let pdf_info = if is_pdf { - let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); - let doc = engine.load_document_from_bytes(&content, None).log_err(); - doc.map(|doc| PdfPageInfo { - current_page: 0, - total_pages: doc.total_pages(), - pdf_bytes: Arc::new(content.clone()), - }) + let (image, pdf_info) = if is_pdf { + if let Ok((pages, full_text)) = create_gpui_images_from_pdf(&content) { + let first_image = pages + .first() + .map(|p| p.image.clone()) + .unwrap_or_else(|| create_gpui_image(content.clone()).unwrap()); + let total_pages = pages.len(); + let pdf_info = PdfPageInfo { + current_page: 0, + total_pages, + pdf_bytes: Arc::new(content.clone()), + pages, + full_text, + }; + (first_image, Some(pdf_info)) + } else { + (create_gpui_image(content.clone())?, None) + } } else { - None + (create_gpui_image(content.clone())?, None) }; - let image = create_gpui_image(content)?; let entity = cx.new(|cx| ImageItem { id: cx.entity_id().as_non_zero_u64().into(), @@ -1073,6 +1112,49 @@ impl LocalImageStore { } } +pub fn create_gpui_images_from_pdf( + pdf_bytes: &[u8], +) -> anyhow::Result<(Vec, Option)> { + let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + let doc = engine.load_document_from_bytes(pdf_bytes, None)?; + let total_pages = doc.total_pages(); + let options = kkpdf_zed::rasterizer::RasterizerOptions { + target_dpi: 144.0, + zoom_factor: 1.0, + dark_mode: false, + saturation_threshold: 0.18, + }; + + let mut pages = Vec::with_capacity(total_pages); + for page_idx in 0..total_pages { + let page = engine.render_page_from_bytes(pdf_bytes, page_idx, options)?; + let mut png_bytes: Vec = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut png_bytes); + let img_buf = image::ImageBuffer::, _>::from_raw( + page.width, + page.height, + page.rgba_buffer.as_ref().clone(), + ) + .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; + + img_buf.write_to(&mut cursor, image::ImageFormat::Png)?; + let image = Arc::new(gpui::Image::from_bytes( + gpui::ImageFormat::Png, + png_bytes, + )); + pages.push(PdfPageEntry { + page_index: page_idx, + width: page.width, + height: page.height, + image, + }); + } + + let full_text = engine.extract_text_from_bytes(pdf_bytes, None).ok(); + + Ok((pages, full_text)) +} + pub fn create_gpui_image_from_pdf_page( pdf_bytes: &[u8], page_index: usize, From 5870e90b9937469376aba350188a6fcc299c55a7 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 09:30:10 +0530 Subject: [PATCH 06/19] feat(pdf): clickable web links, text selection overlays, and background loading --- Cargo.lock | 1 + crates/image_viewer/Cargo.toml | 1 + crates/image_viewer/src/image_viewer.rs | 186 ++++++++++++++++++++++++ crates/project/src/image_store.rs | 35 ++++- 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de5e5175fa..b81ceac6f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8759,6 +8759,7 @@ dependencies = [ "file_icons", "fs", "gpui", + "kkpdf-zed", "language", "log", "menu", diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 46047c3b36..44d1be0255 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -21,6 +21,7 @@ db.workspace = true editor.workspace = true file_icons.workspace = true gpui.workspace = true +kkpdf-zed.workspace = true language.workspace = true log.workspace = true menu.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c3369b12af..58fea825a5 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -74,6 +74,14 @@ const ZOOM_EDITOR_MAX_DIGITS: usize = 4; // MAX_ZOOM is 2000%. const ZOOM_EDITOR_APPROX_CHAR_WIDTH: f32 = 8.0; // Approximate width of one small UI digit. const ZOOM_EDITOR_HORIZONTAL_PADDING: f32 = 12.0; // Extra room for cursor and editor edge padding. +#[derive(Clone, Debug, PartialEq)] +pub struct PdfSelection { + pub page_index: usize, + pub start_seg: usize, + pub end_seg: usize, + pub text: String, +} + pub struct ImageView { image_item: Entity, project: Entity, @@ -89,6 +97,8 @@ pub struct ImageView { show_text_panel: bool, text_buffer: Option>, text_editor: Option>, + pdf_selection: Option, + pdf_mouse_down: bool, } struct DisplayedImage { @@ -202,6 +212,8 @@ impl ImageView { show_text_panel: false, text_buffer: None, text_editor: None, + pdf_selection: None, + pdf_mouse_down: false, } } @@ -315,6 +327,12 @@ impl ImageView { } fn copy_text(&mut self, _: &CopyText, _window: &mut Window, cx: &mut Context) { + if let Some(ref sel) = self.pdf_selection { + if !sel.text.is_empty() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(sel.text.clone())); + return; + } + } if let Some(text) = self.image_item.read(cx).extract_text() { if !text.is_empty() { cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); @@ -322,6 +340,79 @@ impl ImageView { } } + fn handle_pdf_segment_mouse_down( + &mut self, + page_index: usize, + seg_index: usize, + seg_text: String, + cx: &mut Context, + ) { + self.pdf_mouse_down = true; + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: seg_index, + end_seg: seg_index, + text: seg_text, + }); + cx.notify(); + } + + fn handle_pdf_segment_mouse_move( + &mut self, + page_index: usize, + seg_index: usize, + segments: &[kkpdf_zed::PdfTextSegment], + cx: &mut Context, + ) { + if !self.pdf_mouse_down { + return; + } + if let Some(ref mut sel) = self.pdf_selection { + if sel.page_index == page_index && sel.end_seg != seg_index { + sel.end_seg = seg_index; + let min_idx = sel.start_seg.min(seg_index); + let max_idx = sel.start_seg.max(seg_index); + if max_idx < segments.len() { + sel.text = segments[min_idx..=max_idx] + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" "); + } + cx.notify(); + } + } + } + + fn handle_pdf_mouse_up(&mut self, cx: &mut Context) { + if self.pdf_mouse_down { + self.pdf_mouse_down = false; + cx.notify(); + } + } + + fn handle_pdf_page_click( + &mut self, + page_index: usize, + page_text: String, + total_segments: usize, + click_count: usize, + cx: &mut Context, + ) { + if click_count >= 2 && total_segments > 0 { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: 0, + end_seg: total_segments.saturating_sub(1), + text: page_text, + }); + } else if click_count == 1 { + self.pdf_selection = None; + } + self.pdf_mouse_down = false; + cx.notify(); + } + fn toggle_text_panel(&mut self, _: &ToggleTextPanel, window: &mut Window, cx: &mut Context) { self.show_text_panel = !self.show_text_panel; if self.show_text_panel && self.text_editor.is_none() { @@ -739,6 +830,8 @@ impl Item for ImageView { show_text_panel: self.show_text_panel, text_buffer: None, text_editor: None, + pdf_selection: self.pdf_selection.clone(), + pdf_mouse_down: false, }))) } @@ -886,7 +979,15 @@ impl Render for ImageView { .children(pdf_info.pages.into_iter().map(|page| { let page_w = px(page.width as f32 * zoom_level); let page_h = px(page.height as f32 * zoom_level); + let page_w_val = page.width as f32 * zoom_level; + let page_h_val = page.height as f32 * zoom_level; let page_idx = page.page_index; + let page_text = page.page_text.clone(); + let total_segs = page.text_segments.len(); + let segments_arc = Arc::new(page.text_segments); + let links = page.links; + let pdf_selection = self.pdf_selection.clone(); + v_flex() .id(("pdf-page-container", page_idx)) .items_center() @@ -894,17 +995,102 @@ impl Render for ImageView { .child( div() .id(("pdf-page-canvas", page_idx)) + .relative() .w(page_w) .h(page_h) .shadow_md() .border_1() .border_color(border_color) .bg(gpui::white()) + .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.handle_pdf_page_click(page_idx, page_text.clone(), total_segs, event.click_count, cx); + })) + .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + })) .child( img(page.image) .id(("pdf-page-img", page_idx)) .size_full() + .absolute() ) + .children((0..total_segs).map(|seg_idx| { + let seg = &segments_arc[seg_idx]; + let is_selected = pdf_selection.as_ref().is_some_and(|sel| { + sel.page_index == page_idx && { + let min_idx = sel.start_seg.min(sel.end_seg); + let max_idx = sel.start_seg.max(sel.end_seg); + seg_idx >= min_idx && seg_idx <= max_idx + } + }); + let seg_left = px(seg.x * page_w_val); + let seg_top = px(seg.y * page_h_val); + let seg_w = px(seg.width * page_w_val); + let seg_h = px(seg.height * page_h_val); + let seg_text = seg.text.clone(); + let segs_ref = Arc::clone(&segments_arc); + + div() + .id(ElementId::Name(format!("pdf-text-seg-{page_idx}-{seg_idx}").into())) + .absolute() + .left(seg_left) + .top(seg_top) + .w(seg_w) + .h(seg_h) + .cursor(gpui::CursorStyle::IBeam) + .when(is_selected, |s| s.bg(gpui::rgba(0x3b82f644)).rounded_xs()) + .when(!is_selected, |s| { + s.hover(|h| h.bg(gpui::rgba(0x3b82f618)).rounded_xs()) + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { + this.handle_pdf_segment_mouse_down(page_idx, seg_idx, seg_text.clone(), cx); + cx.stop_propagation(); + }), + ) + .on_mouse_move(cx.listener(move |this, _event: &MouseMoveEvent, _window, cx| { + this.handle_pdf_segment_mouse_move(page_idx, seg_idx, &segs_ref, cx); + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + cx.stop_propagation(); + }), + ) + })) + .children(links.into_iter().enumerate().map(|(link_idx, link)| { + let link_left = px(link.x * page_w_val); + let link_top = px(link.y * page_h_val); + let link_w = px(link.width * page_w_val); + let link_h = px(link.height * page_h_val); + let link_url = link.url.clone(); + let link_url_tooltip = link.url.clone(); + + div() + .id(ElementId::Name(format!("pdf-link-{page_idx}-{link_idx}").into())) + .absolute() + .left(link_left) + .top(link_top) + .w(link_w) + .h(link_h) + .cursor(gpui::CursorStyle::PointingHand) + .hover(|s| { + s.bg(gpui::rgba(0x3b82f633)) + .border_1() + .border_color(gpui::rgba(0x3b82f688)) + .rounded_xs() + }) + .tooltip(Tooltip::text(format!("Open: {}", link_url_tooltip))) + .on_mouse_down( + MouseButton::Left, + move |_event: &MouseDownEvent, _window, cx| { + cx.open_url(&link_url); + cx.stop_propagation(); + }, + ) + })) ) .child( Label::new(format!("Page {} of {}", page_idx + 1, total_pages)) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index d780669720..457fc5ec20 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -116,6 +116,9 @@ pub struct PdfPageEntry { pub width: u32, pub height: u32, pub image: Arc, + pub links: Vec, + pub text_segments: Vec, + pub page_text: String, } #[derive(Clone)] @@ -328,13 +331,18 @@ impl ImageItem { 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 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 { - if let Ok((pages, full_text)) = create_gpui_images_from_pdf(&bytes) { + 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 first_image = pages @@ -818,11 +826,16 @@ impl ImageStoreImpl for Entity { 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 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 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 first_image = pages .first() .map(|p| p.image.clone()) @@ -1115,7 +1128,8 @@ impl LocalImageStore { pub fn create_gpui_images_from_pdf( pdf_bytes: &[u8], ) -> anyhow::Result<(Vec, Option)> { - let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + let engine = kkpdf_zed::PdfiumEngine::new(); + let doc_details = engine.extract_document_details(pdf_bytes).unwrap_or_default(); let doc = engine.load_document_from_bytes(pdf_bytes, None)?; let total_pages = doc.total_pages(); let options = kkpdf_zed::rasterizer::RasterizerOptions { @@ -1142,15 +1156,28 @@ pub fn create_gpui_images_from_pdf( gpui::ImageFormat::Png, png_bytes, )); + + let page_detail = doc_details.pages.get(page_idx); + let links = page_detail.map(|d| d.links.clone()).unwrap_or_default(); + let text_segments = page_detail.map(|d| d.text_segments.clone()).unwrap_or_default(); + let page_text = page_detail.map(|d| d.text.clone()).unwrap_or_default(); + pages.push(PdfPageEntry { page_index: page_idx, width: page.width, height: page.height, image, + links, + text_segments, + page_text, }); } - let full_text = engine.extract_text_from_bytes(pdf_bytes, None).ok(); + let full_text = if !doc_details.full_text.is_empty() { + Some(doc_details.full_text) + } else { + engine.extract_text_from_bytes(pdf_bytes, None).ok() + }; Ok((pages, full_text)) } From bbd418d5a245e4e806fba92180cdb12f99e2aeb7 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 10:06:41 +0530 Subject: [PATCH 07/19] feat(image_viewer): browser-style mouse drag text selection and right-click context menu for PDF viewer --- crates/image_viewer/src/image_viewer.rs | 182 ++++++++++++++++++++---- 1 file changed, 153 insertions(+), 29 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 58fea825a5..d5bf393a37 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -10,12 +10,12 @@ use editor::{ }; use file_icons::FileIcons; use gpui::{ - AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, - FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement, - IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement, PinchEvent, Pixels, Point, Render, RenderImage, ScrollDelta, ScrollWheelEvent, - Style, Styled, Subscription, Task, WeakEntity, Window, actions, checkerboard, div, img, point, - px, size, + AnyElement, App, Bounds, Context, DismissEvent, DispatchPhase, Element, ElementId, Entity, + EventEmitter, FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, + InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement, PinchEvent, Pixels, Point, Render, RenderImage, ScrollDelta, + ScrollWheelEvent, Style, Styled, Subscription, Task, WeakEntity, Window, actions, anchored, + checkerboard, deferred, div, img, point, px, size, }; use language::File as _; use persistence::ImageViewerDb; @@ -24,7 +24,7 @@ use project::{ }; use settings::Settings; use theme_settings::ThemeSettings; -use ui::{Divider, Tooltip, prelude::*}; +use ui::{ContextMenu, Divider, Tooltip, prelude::*}; use util::{ResultExt as _, paths::PathExt}; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -99,6 +99,7 @@ pub struct ImageView { text_editor: Option>, pdf_selection: Option, pdf_mouse_down: bool, + context_menu: Option<(Entity, Point, Subscription)>, } struct DisplayedImage { @@ -214,6 +215,7 @@ impl ImageView { text_editor: None, pdf_selection: None, pdf_mouse_down: false, + context_menu: None, } } @@ -340,20 +342,104 @@ impl ImageView { } } + fn select_all(&mut self, _: &SelectAll, _window: &mut Window, cx: &mut Context) { + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + if let Some(pdf_info) = pdf_info { + let cur_page = self.image_item.read(cx).current_page(); + let target_page = self + .pdf_selection + .as_ref() + .map(|s| s.page_index) + .unwrap_or(cur_page); + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == target_page) { + let total_segs = page.text_segments.len(); + if total_segs > 0 { + self.pdf_selection = Some(PdfSelection { + page_index: target_page, + start_seg: 0, + end_seg: total_segs.saturating_sub(1), + text: page.page_text.clone(), + }); + cx.notify(); + } + } + } + } + + fn deploy_context_menu( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let has_selection = self + .pdf_selection + .as_ref() + .is_some_and(|s| !s.text.is_empty()); + let is_pdf = self.image_item.read(cx).is_pdf(); + let has_doc_text = self + .image_item + .read(cx) + .extract_text() + .is_some_and(|t| !t.is_empty()); + let can_copy = has_selection || has_doc_text; + let focus_handle = self.focus_handle.clone(); + + let context_menu = ContextMenu::build(window, cx, move |menu, _, _| { + menu.context(focus_handle).map(|menu| { + menu.action_disabled_when(!can_copy, "Copy", Box::new(CopyText)) + .when(is_pdf, |menu| { + menu.action("Select All", Box::new(SelectAll)) + .separator() + .action("Toggle Text Panel", Box::new(ToggleTextPanel)) + }) + .separator() + .action("Zoom In", Box::new(ZoomIn)) + .action("Zoom Out", Box::new(ZoomOut)) + .action("Reset Zoom", Box::new(ResetZoom)) + .action("Fit to View", Box::new(FitToView)) + .separator() + .action("Reveal in File Manager", Box::new(RevealInFileManager)) + }) + }); + + window.focus(&context_menu.focus_handle(cx), cx); + let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { + this.context_menu.take(); + cx.notify(); + }); + self.context_menu = Some((context_menu, position, subscription)); + cx.notify(); + } + fn handle_pdf_segment_mouse_down( &mut self, page_index: usize, seg_index: usize, seg_text: String, + click_count: usize, cx: &mut Context, ) { self.pdf_mouse_down = true; - self.pdf_selection = Some(PdfSelection { - page_index, - start_seg: seg_index, - end_seg: seg_index, - text: seg_text, - }); + if click_count >= 3 { + if let Some(pdf_info) = self.image_item.read(cx).pdf_info.as_ref() { + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: 0, + end_seg: page.text_segments.len().saturating_sub(1), + text: page.page_text.clone(), + }); + } + } + } else { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: seg_index, + end_seg: seg_index, + text: seg_text, + }); + } cx.notify(); } @@ -391,22 +477,24 @@ impl ImageView { } } - fn handle_pdf_page_click( + fn handle_pdf_canvas_mouse_down( &mut self, page_index: usize, - page_text: String, - total_segments: usize, click_count: usize, cx: &mut Context, ) { - if click_count >= 2 && total_segments > 0 { - self.pdf_selection = Some(PdfSelection { - page_index, - start_seg: 0, - end_seg: total_segments.saturating_sub(1), - text: page_text, - }); - } else if click_count == 1 { + if click_count >= 2 { + if let Some(pdf_info) = self.image_item.read(cx).pdf_info.as_ref() { + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: 0, + end_seg: page.text_segments.len().saturating_sub(1), + text: page.page_text.clone(), + }); + } + } + } else { self.pdf_selection = None; } self.pdf_mouse_down = false; @@ -832,6 +920,7 @@ impl Item for ImageView { text_editor: None, pdf_selection: self.pdf_selection.clone(), pdf_mouse_down: false, + context_menu: None, }))) } @@ -956,6 +1045,7 @@ impl Render for ImageView { .on_action(cx.listener(Self::last_page)) .on_action(cx.listener(Self::copy_text)) .on_action(cx.listener(Self::toggle_text_panel)) + .on_action(cx.listener(Self::select_all)) .size_full() .relative() .bg(cx.theme().colors().editor_background) @@ -982,7 +1072,6 @@ impl Render for ImageView { let page_w_val = page.width as f32 * zoom_level; let page_h_val = page.height as f32 * zoom_level; let page_idx = page.page_index; - let page_text = page.page_text.clone(); let total_segs = page.text_segments.len(); let segments_arc = Arc::new(page.text_segments); let links = page.links; @@ -1002,12 +1091,16 @@ impl Render for ImageView { .border_1() .border_color(border_color) .bg(gpui::white()) + .cursor(gpui::CursorStyle::IBeam) .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.handle_pdf_page_click(page_idx, page_text.clone(), total_segs, event.click_count, cx); + this.handle_pdf_canvas_mouse_down(page_idx, event.click_count, cx); })) .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { this.handle_pdf_mouse_up(cx); })) + .on_mouse_down(MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, window, cx| { + this.deploy_context_menu(event.position, window, cx); + })) .child( img(page.image) .id(("pdf-page-img", page_idx)) @@ -1028,6 +1121,7 @@ impl Render for ImageView { let seg_w = px(seg.width * page_w_val); let seg_h = px(seg.height * page_h_val); let seg_text = seg.text.clone(); + let seg_text_clone = seg.text.clone(); let segs_ref = Arc::clone(&segments_arc); div() @@ -1040,12 +1134,12 @@ impl Render for ImageView { .cursor(gpui::CursorStyle::IBeam) .when(is_selected, |s| s.bg(gpui::rgba(0x3b82f644)).rounded_xs()) .when(!is_selected, |s| { - s.hover(|h| h.bg(gpui::rgba(0x3b82f618)).rounded_xs()) + s.hover(|h| h.bg(gpui::rgba(0x3b82f618)).rounded_xs()) }) .on_mouse_down( MouseButton::Left, - cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { - this.handle_pdf_segment_mouse_down(page_idx, seg_idx, seg_text.clone(), cx); + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.handle_pdf_segment_mouse_down(page_idx, seg_idx, seg_text.clone(), event.click_count, cx); cx.stop_propagation(); }), ) @@ -1059,6 +1153,24 @@ impl Render for ImageView { cx.stop_propagation(); }), ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let need_select = this.pdf_selection.as_ref().map_or(true, |s| { + s.page_index != page_idx || (seg_idx < s.start_seg.min(s.end_seg) || seg_idx > s.start_seg.max(s.end_seg)) + }); + if need_select { + this.pdf_selection = Some(PdfSelection { + page_index: page_idx, + start_seg: seg_idx, + end_seg: seg_idx, + text: seg_text_clone.clone(), + }); + } + this.deploy_context_menu(event.position, window, cx); + cx.stop_propagation(); + }), + ) })) .children(links.into_iter().enumerate().map(|(link_idx, link)| { let link_left = px(link.x * page_w_val); @@ -1142,6 +1254,9 @@ impl Render for ImageView { .on_pinch(cx.listener(Self::handle_pinch)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) + .on_mouse_down(MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.deploy_context_menu(event.position, window, cx); + })) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) @@ -1150,6 +1265,15 @@ impl Render for ImageView { container.into_any_element() } }) + .children(self.context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::Anchor::TopLeft) + .child(menu.clone()), + ) + .with_priority(3) + })) } } From 6c86ad1266433e9b95dfcb79733840177f9706e9 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 10:29:46 +0530 Subject: [PATCH 08/19] perf: eliminate PDF scroll lag and load latency via direct RenderImage and canvas hit-testing --- crates/image_viewer/src/image_viewer.rs | 348 +++++++++++++++--------- crates/project/src/image_store.rs | 63 ++--- 2 files changed, 244 insertions(+), 167 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index d5bf393a37..6e7a6f3aa1 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1,7 +1,7 @@ mod image_info; mod image_viewer_settings; -use std::{path::Path, sync::Arc}; +use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc, sync::Arc}; use anyhow::Context as _; use editor::{ @@ -15,7 +15,7 @@ use gpui::{ InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, PinchEvent, Pixels, Point, Render, RenderImage, ScrollDelta, ScrollWheelEvent, Style, Styled, Subscription, Task, WeakEntity, Window, actions, anchored, - checkerboard, deferred, div, img, point, px, size, + canvas, checkerboard, deferred, div, img, point, px, size, }; use language::File as _; use persistence::ImageViewerDb; @@ -100,6 +100,7 @@ pub struct ImageView { pdf_selection: Option, pdf_mouse_down: bool, context_menu: Option<(Entity, Point, Subscription)>, + page_bounds: Rc>>>, } struct DisplayedImage { @@ -216,6 +217,7 @@ impl ImageView { pdf_selection: None, pdf_mouse_down: false, context_menu: None, + page_bounds: Rc::new(RefCell::new(HashMap::default())), } } @@ -412,60 +414,158 @@ impl ImageView { cx.notify(); } - fn handle_pdf_segment_mouse_down( + fn find_segment_at( + segments: &[kkpdf_zed::PdfTextSegment], + norm_x: f32, + norm_y: f32, + ) -> Option { + if segments.is_empty() { + return None; + } + + // 1. Exact bounding box hit + for (i, seg) in segments.iter().enumerate() { + if norm_x >= seg.x + && norm_x <= seg.x + seg.width + && norm_y >= seg.y + && norm_y <= seg.y + seg.height + { + return Some(i); + } + } + + // 2. Line-level hit with horizontal proximity + let mut closest_on_line: Option<(usize, f32)> = None; + for (i, seg) in segments.iter().enumerate() { + let line_top = seg.y - seg.height * 0.2; + let line_bottom = seg.y + seg.height * 1.2; + if norm_y >= line_top && norm_y <= line_bottom { + let seg_center_x = seg.x + seg.width * 0.5; + let dist_x = (norm_x - seg_center_x).abs(); + if closest_on_line.as_ref().map_or(true, |(_, d)| dist_x < *d) { + closest_on_line = Some((i, dist_x)); + } + } + } + if let Some((idx, _)) = closest_on_line { + return Some(idx); + } + + // 3. Closest segment overall + let mut closest: Option<(usize, f32)> = None; + for (i, seg) in segments.iter().enumerate() { + let center_x = seg.x + seg.width * 0.5; + let center_y = seg.y + seg.height * 0.5; + let dist_sq = (norm_x - center_x).powi(2) + (norm_y - center_y).powi(2); + if closest.as_ref().map_or(true, |(_, d)| dist_sq < *d) { + closest = Some((i, dist_sq)); + } + } + closest.map(|(idx, _)| idx) + } + + fn handle_pdf_canvas_mouse_down( &mut self, page_index: usize, - seg_index: usize, - seg_text: String, + click_pos: Point, click_count: usize, cx: &mut Context, ) { - self.pdf_mouse_down = true; + let bounds = self.page_bounds.borrow().get(&page_index).copied(); + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + let Some(pdf_info) = pdf_info else { return; }; + let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) else { return; }; + if click_count >= 3 { - if let Some(pdf_info) = self.image_item.read(cx).pdf_info.as_ref() { - if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { - self.pdf_selection = Some(PdfSelection { - page_index, - start_seg: 0, - end_seg: page.text_segments.len().saturating_sub(1), - text: page.page_text.clone(), - }); - } - } - } else { + self.pdf_mouse_down = false; self.pdf_selection = Some(PdfSelection { page_index, - start_seg: seg_index, - end_seg: seg_index, - text: seg_text, + start_seg: 0, + end_seg: page.text_segments.len().saturating_sub(1), + text: page.page_text.clone(), }); + cx.notify(); + return; + } + + let Some(bounds) = bounds else { return; }; + let b_w: f32 = bounds.size.width.into(); + let b_h: f32 = bounds.size.height.into(); + if b_w <= 0.0 || b_h <= 0.0 { + return; + } + + let b_ox: f32 = bounds.origin.x.into(); + let b_oy: f32 = bounds.origin.y.into(); + let p_x: f32 = click_pos.x.into(); + let p_y: f32 = click_pos.y.into(); + let norm_x = ((p_x - b_ox) / b_w).clamp(0.0, 1.0); + let norm_y = ((p_y - b_oy) / b_h).clamp(0.0, 1.0); + + if let Some(idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + self.pdf_mouse_down = true; + let seg = &page.text_segments[idx]; + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: idx, + end_seg: idx, + text: seg.text.clone(), + }); + } else { + self.pdf_mouse_down = false; + self.pdf_selection = None; } cx.notify(); } - fn handle_pdf_segment_mouse_move( + fn handle_pdf_canvas_mouse_move( &mut self, page_index: usize, - seg_index: usize, - segments: &[kkpdf_zed::PdfTextSegment], + cursor_pos: Point, cx: &mut Context, ) { if !self.pdf_mouse_down { return; } - if let Some(ref mut sel) = self.pdf_selection { - if sel.page_index == page_index && sel.end_seg != seg_index { - sel.end_seg = seg_index; - let min_idx = sel.start_seg.min(seg_index); - let max_idx = sel.start_seg.max(seg_index); - if max_idx < segments.len() { - sel.text = segments[min_idx..=max_idx] - .iter() - .map(|s| s.text.as_str()) - .collect::>() - .join(" "); + let Some(ref sel) = self.pdf_selection else { return; }; + if sel.page_index != page_index { + return; + } + + let bounds = self.page_bounds.borrow().get(&page_index).copied(); + let Some(bounds) = bounds else { return; }; + let b_w: f32 = bounds.size.width.into(); + let b_h: f32 = bounds.size.height.into(); + if b_w <= 0.0 || b_h <= 0.0 { + return; + } + + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + let Some(pdf_info) = pdf_info else { return; }; + let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) else { return; }; + + let b_ox: f32 = bounds.origin.x.into(); + let b_oy: f32 = bounds.origin.y.into(); + let p_x: f32 = cursor_pos.x.into(); + let p_y: f32 = cursor_pos.y.into(); + let norm_x = ((p_x - b_ox) / b_w).clamp(0.0, 1.0); + let norm_y = ((p_y - b_oy) / b_h).clamp(0.0, 1.0); + + if let Some(seg_index) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + if let Some(ref mut sel) = self.pdf_selection { + if sel.end_seg != seg_index { + sel.end_seg = seg_index; + let min_idx = sel.start_seg.min(seg_index); + let max_idx = sel.start_seg.max(seg_index); + if max_idx < page.text_segments.len() { + sel.text = page.text_segments[min_idx..=max_idx] + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" "); + } + cx.notify(); } - cx.notify(); } } } @@ -477,28 +577,46 @@ impl ImageView { } } - fn handle_pdf_canvas_mouse_down( + fn handle_pdf_canvas_right_click( &mut self, page_index: usize, - click_count: usize, + click_pos: Point, + window: &mut Window, cx: &mut Context, ) { - if click_count >= 2 { - if let Some(pdf_info) = self.image_item.read(cx).pdf_info.as_ref() { - if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { - self.pdf_selection = Some(PdfSelection { - page_index, - start_seg: 0, - end_seg: page.text_segments.len().saturating_sub(1), - text: page.page_text.clone(), - }); + if let Some(bounds) = self.page_bounds.borrow().get(&page_index).copied() { + let b_w: f32 = bounds.size.width.into(); + let b_h: f32 = bounds.size.height.into(); + if b_w > 0.0 && b_h > 0.0 { + let b_ox: f32 = bounds.origin.x.into(); + let b_oy: f32 = bounds.origin.y.into(); + let p_x: f32 = click_pos.x.into(); + let p_y: f32 = click_pos.y.into(); + let norm_x = ((p_x - b_ox) / b_w).clamp(0.0, 1.0); + let norm_y = ((p_y - b_oy) / b_h).clamp(0.0, 1.0); + + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + if let Some(pdf_info) = pdf_info { + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { + if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + let need_select = self.pdf_selection.as_ref().map_or(true, |s| { + s.page_index != page_index + || (seg_idx < s.start_seg.min(s.end_seg) || seg_idx > s.start_seg.max(s.end_seg)) + }); + if need_select { + self.pdf_selection = Some(PdfSelection { + page_index, + start_seg: seg_idx, + end_seg: seg_idx, + text: page.text_segments[seg_idx].text.clone(), + }); + } + } + } } } - } else { - self.pdf_selection = None; } - self.pdf_mouse_down = false; - cx.notify(); + self.deploy_context_menu(click_pos, window, cx); } fn toggle_text_panel(&mut self, _: &ToggleTextPanel, window: &mut Window, cx: &mut Context) { @@ -921,6 +1039,7 @@ impl Item for ImageView { pdf_selection: self.pdf_selection.clone(), pdf_mouse_down: false, context_menu: None, + page_bounds: Rc::new(RefCell::new(HashMap::default())), }))) } @@ -1060,6 +1179,9 @@ impl Render for ImageView { .track_scroll(&self.pdf_scroll_handle) .overflow_y_scroll() .size_full() + .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + })) .child( v_flex() .w_full() @@ -1072,7 +1194,6 @@ impl Render for ImageView { let page_w_val = page.width as f32 * zoom_level; let page_h_val = page.height as f32 * zoom_level; let page_idx = page.page_index; - let total_segs = page.text_segments.len(); let segments_arc = Arc::new(page.text_segments); let links = page.links; let pdf_selection = self.pdf_selection.clone(); @@ -1092,86 +1213,67 @@ impl Render for ImageView { .border_color(border_color) .bg(gpui::white()) .cursor(gpui::CursorStyle::IBeam) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.handle_pdf_canvas_mouse_down(page_idx, event.click_count, cx); - })) - .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.handle_pdf_mouse_up(cx); - })) - .on_mouse_down(MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, window, cx| { - this.deploy_context_menu(event.position, window, cx); + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.handle_pdf_canvas_mouse_down(page_idx, event.position, event.click_count, cx); + }), + ) + .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { + this.handle_pdf_canvas_mouse_move(page_idx, event.position, cx); })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + this.handle_pdf_canvas_right_click(page_idx, event.position, window, cx); + }), + ) + .child({ + let page_bounds = self.page_bounds.clone(); + canvas( + move |bounds: Bounds, _window: &mut Window, _cx: &mut App| { + page_bounds.borrow_mut().insert(page_idx, bounds); + }, + |_bounds: Bounds, _state: (), _window: &mut Window, _cx: &mut App| {}, + ) + .absolute() + .size_full() + }) .child( img(page.image) .id(("pdf-page-img", page_idx)) .size_full() - .absolute() + .absolute(), ) - .children((0..total_segs).map(|seg_idx| { - let seg = &segments_arc[seg_idx]; - let is_selected = pdf_selection.as_ref().is_some_and(|sel| { - sel.page_index == page_idx && { + .children( + pdf_selection + .as_ref() + .filter(|s| s.page_index == page_idx) + .into_iter() + .flat_map(|sel| { let min_idx = sel.start_seg.min(sel.end_seg); let max_idx = sel.start_seg.max(sel.end_seg); - seg_idx >= min_idx && seg_idx <= max_idx - } - }); - let seg_left = px(seg.x * page_w_val); - let seg_top = px(seg.y * page_h_val); - let seg_w = px(seg.width * page_w_val); - let seg_h = px(seg.height * page_h_val); - let seg_text = seg.text.clone(); - let seg_text_clone = seg.text.clone(); - let segs_ref = Arc::clone(&segments_arc); - - div() - .id(ElementId::Name(format!("pdf-text-seg-{page_idx}-{seg_idx}").into())) - .absolute() - .left(seg_left) - .top(seg_top) - .w(seg_w) - .h(seg_h) - .cursor(gpui::CursorStyle::IBeam) - .when(is_selected, |s| s.bg(gpui::rgba(0x3b82f644)).rounded_xs()) - .when(!is_selected, |s| { - s.hover(|h| h.bg(gpui::rgba(0x3b82f618)).rounded_xs()) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - this.handle_pdf_segment_mouse_down(page_idx, seg_idx, seg_text.clone(), event.click_count, cx); - cx.stop_propagation(); - }), - ) - .on_mouse_move(cx.listener(move |this, _event: &MouseMoveEvent, _window, cx| { - this.handle_pdf_segment_mouse_move(page_idx, seg_idx, &segs_ref, cx); - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.handle_pdf_mouse_up(cx); - cx.stop_propagation(); - }), - ) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |this, event: &MouseDownEvent, window, cx| { - let need_select = this.pdf_selection.as_ref().map_or(true, |s| { - s.page_index != page_idx || (seg_idx < s.start_seg.min(s.end_seg) || seg_idx > s.start_seg.max(s.end_seg)) - }); - if need_select { - this.pdf_selection = Some(PdfSelection { - page_index: page_idx, - start_seg: seg_idx, - end_seg: seg_idx, - text: seg_text_clone.clone(), - }); - } - this.deploy_context_menu(event.position, window, cx); - cx.stop_propagation(); - }), - ) - })) + let segs = &segments_arc; + (min_idx..=max_idx.min(segs.len().saturating_sub(1))).map(move |seg_idx| { + let seg = &segs[seg_idx]; + div() + .id(ElementId::Name(format!("pdf-sel-highlight-{page_idx}-{seg_idx}").into())) + .absolute() + .left(px(seg.x * page_w_val)) + .top(px(seg.y * page_h_val)) + .w(px(seg.width * page_w_val)) + .h(px(seg.height * page_h_val)) + .bg(gpui::rgba(0x3b82f644)) + .rounded_xs() + }) + }), + ) .children(links.into_iter().enumerate().map(|(link_idx, link)| { let link_left = px(link.x * page_w_val); let link_top = px(link.y * page_h_val); diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 457fc5ec20..89be8cb52b 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -115,7 +115,7 @@ pub struct PdfPageEntry { pub page_index: usize, pub width: u32, pub height: u32, - pub image: Arc, + pub image: Arc, pub links: Vec, pub text_segments: Vec, pub page_text: String, @@ -286,7 +286,6 @@ impl ImageItem { info.current_page = page_index; if let Some(page) = info.pages.get(page_index) { - self.image = page.image.clone(); self.image_metadata = Some(ImageMetadata { width: page.width, height: page.height, @@ -345,16 +344,11 @@ impl ImageItem { 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 first_image = pages - .get(target_page) - .map(|p| p.image.clone()) - .unwrap_or_else(|| create_gpui_image(bytes.clone()).unwrap()); let (width, height) = pages .get(target_page) .map(|p| (p.width, p.height)) .unwrap_or((0, 0)); this.update(cx, |this, cx| { - this.image = first_image; this.pdf_info = Some(PdfPageInfo { current_page: target_page, total_pages, @@ -751,10 +745,6 @@ impl RemoteImageStore { 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 first_image = pages - .first() - .map(|p| p.image.clone()) - .unwrap_or_else(|| create_gpui_image(content.clone()).unwrap()); let total_pages = pages.len(); let pdf_info = PdfPageInfo { current_page: 0, @@ -763,7 +753,8 @@ impl RemoteImageStore { pages, full_text, }; - (first_image, Some(pdf_info)) + let dummy_img = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![])); + (dummy_img, Some(pdf_info)) } else { (create_gpui_image(content.clone())?, None) } @@ -836,10 +827,6 @@ impl ImageStoreImpl for Entity { create_gpui_images_from_pdf(&content_clone) }).await; if let Ok((pages, full_text)) = pdf_res { - let first_image = pages - .first() - .map(|p| p.image.clone()) - .unwrap_or_else(|| create_gpui_image(content.clone()).unwrap()); let total_pages = pages.len(); let pdf_info = PdfPageInfo { current_page: 0, @@ -848,7 +835,8 @@ impl ImageStoreImpl for Entity { pages, full_text, }; - (first_image, Some(pdf_info)) + let dummy_img = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![])); + (dummy_img, Some(pdf_info)) } else { (create_gpui_image(content.clone())?, None) } @@ -1129,9 +1117,6 @@ pub fn create_gpui_images_from_pdf( pdf_bytes: &[u8], ) -> anyhow::Result<(Vec, Option)> { let engine = kkpdf_zed::PdfiumEngine::new(); - let doc_details = engine.extract_document_details(pdf_bytes).unwrap_or_default(); - let doc = engine.load_document_from_bytes(pdf_bytes, None)?; - let total_pages = doc.total_pages(); let options = kkpdf_zed::rasterizer::RasterizerOptions { target_dpi: 144.0, zoom_factor: 1.0, @@ -1139,44 +1124,34 @@ pub fn create_gpui_images_from_pdf( saturation_threshold: 0.18, }; - let mut pages = Vec::with_capacity(total_pages); - for page_idx in 0..total_pages { - let page = engine.render_page_from_bytes(pdf_bytes, page_idx, options)?; - let mut png_bytes: Vec = Vec::new(); - let mut cursor = std::io::Cursor::new(&mut png_bytes); + let doc_res = engine.render_and_extract_document_from_bytes(pdf_bytes, options)?; + let mut pages = Vec::with_capacity(doc_res.pages.len()); + + for page in doc_res.pages { let img_buf = image::ImageBuffer::, _>::from_raw( page.width, page.height, - page.rgba_buffer.as_ref().clone(), + page.rgba_buffer, ) .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; - img_buf.write_to(&mut cursor, image::ImageFormat::Png)?; - let image = Arc::new(gpui::Image::from_bytes( - gpui::ImageFormat::Png, - png_bytes, - )); - - let page_detail = doc_details.pages.get(page_idx); - let links = page_detail.map(|d| d.links.clone()).unwrap_or_default(); - let text_segments = page_detail.map(|d| d.text_segments.clone()).unwrap_or_default(); - let page_text = page_detail.map(|d| d.text.clone()).unwrap_or_default(); + let render_image = Arc::new(gpui::RenderImage::new(vec![image::Frame::new(img_buf)])); pages.push(PdfPageEntry { - page_index: page_idx, + page_index: page.page_index, width: page.width, height: page.height, - image, - links, - text_segments, - page_text, + image: render_image, + links: page.links, + text_segments: page.text_segments, + page_text: page.text, }); } - let full_text = if !doc_details.full_text.is_empty() { - Some(doc_details.full_text) + let full_text = if !doc_res.full_text.is_empty() { + Some(doc_res.full_text) } else { - engine.extract_text_from_bytes(pdf_bytes, None).ok() + None }; Ok((pages, full_text)) From 899c8216bc88189555575627ee14b3484466021b Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 11:01:59 +0530 Subject: [PATCH 09/19] image_viewer: fix page navigation, restore focus, and add vertical scrollbar --- crates/image_viewer/src/image_viewer.rs | 138 ++++++++++++++++-------- 1 file changed, 94 insertions(+), 44 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 6e7a6f3aa1..4709dccada 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -24,7 +24,9 @@ use project::{ }; use settings::Settings; use theme_settings::ThemeSettings; -use ui::{ContextMenu, Divider, Tooltip, prelude::*}; +use ui::{ + ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*, +}; use util::{ResultExt as _, paths::PathExt}; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -231,8 +233,6 @@ impl ImageView { ImageItemEvent::MetadataUpdated | ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => { - let image = self.image_item.read(cx).image.clone(); - self.pending_image = Some(image); self.image_size = self .image_item .read(cx) @@ -288,46 +288,56 @@ impl ImageView { } fn next_page(&mut self, _: &NextPage, _window: &mut Window, cx: &mut Context) { - let changed = self.image_item.update(cx, |item, cx| { - item.next_page(cx) - }); - if changed { - let cur = self.image_item.read(cx).current_page(); - self.pdf_scroll_handle.scroll_to_top_of_item(cur); - cx.notify(); + let total_pages = self.image_item.read(cx).total_pages(); + if total_pages == 0 { + return; } + let cur = self.pdf_scroll_handle.top_item(); + let target = (cur + 1).min(total_pages - 1); + self.image_item.update(cx, |item, cx| { + item.set_page(target, cx); + }); + self.pdf_scroll_handle.scroll_to_top_of_item(target); + cx.notify(); } fn previous_page(&mut self, _: &PreviousPage, _window: &mut Window, cx: &mut Context) { - let changed = self.image_item.update(cx, |item, cx| { - item.previous_page(cx) - }); - if changed { - let cur = self.image_item.read(cx).current_page(); - self.pdf_scroll_handle.scroll_to_top_of_item(cur); - cx.notify(); + let total_pages = self.image_item.read(cx).total_pages(); + if total_pages == 0 { + return; } + let cur = self.pdf_scroll_handle.top_item(); + let target = cur.saturating_sub(1); + self.image_item.update(cx, |item, cx| { + item.set_page(target, cx); + }); + self.pdf_scroll_handle.scroll_to_top_of_item(target); + cx.notify(); } fn first_page(&mut self, _: &FirstPage, _window: &mut Window, cx: &mut Context) { - let changed = self.image_item.update(cx, |item, cx| { - item.first_page(cx) - }); - if changed { - self.pdf_scroll_handle.scroll_to_top_of_item(0); - cx.notify(); + let total_pages = self.image_item.read(cx).total_pages(); + if total_pages == 0 { + return; } + self.image_item.update(cx, |item, cx| { + item.set_page(0, cx); + }); + self.pdf_scroll_handle.scroll_to_top_of_item(0); + cx.notify(); } fn last_page(&mut self, _: &LastPage, _window: &mut Window, cx: &mut Context) { - let changed = self.image_item.update(cx, |item, cx| { - item.last_page(cx) - }); - if changed { - let cur = self.image_item.read(cx).current_page(); - self.pdf_scroll_handle.scroll_to_top_of_item(cur); - cx.notify(); + let total_pages = self.image_item.read(cx).total_pages(); + if total_pages == 0 { + return; } + let target = total_pages - 1; + self.image_item.update(cx, |item, cx| { + item.set_page(target, cx); + }); + self.pdf_scroll_handle.scroll_to_top_of_item(target); + cx.notify(); } fn copy_text(&mut self, _: &CopyText, _window: &mut Window, cx: &mut Context) { @@ -347,7 +357,7 @@ impl ImageView { fn select_all(&mut self, _: &SelectAll, _window: &mut Window, cx: &mut Context) { let pdf_info = self.image_item.read(cx).pdf_info.clone(); if let Some(pdf_info) = pdf_info { - let cur_page = self.image_item.read(cx).current_page(); + let cur_page = self.pdf_scroll_handle.top_item(); let target_page = self .pdf_selection .as_ref() @@ -703,9 +713,10 @@ impl ImageView { fn handle_mouse_down( &mut self, event: &MouseDownEvent, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { + window.focus(&self.focus_handle, cx); if event.button == MouseButton::Left || event.button == MouseButton::Middle { self.last_mouse_position = Some(event.position); cx.notify(); @@ -1146,7 +1157,7 @@ impl Focusable for ImageView { } impl Render for ImageView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let pdf_info = self.image_item.read(cx).pdf_info.clone(); div() @@ -1165,6 +1176,12 @@ impl Render for ImageView { .on_action(cx.listener(Self::copy_text)) .on_action(cx.listener(Self::toggle_text_panel)) .on_action(cx.listener(Self::select_all)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); + }), + ) .size_full() .relative() .bg(cx.theme().colors().editor_background) @@ -1175,16 +1192,32 @@ impl Render for ImageView { let total_pages = pdf_info.total_pages; let main_pdf_view = div() - .id("pdf-scroll-container") - .track_scroll(&self.pdf_scroll_handle) - .overflow_y_scroll() + .id("pdf-viewport-wrapper") + .relative() .size_full() - .on_mouse_up(MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.handle_pdf_mouse_up(cx); - })) .child( - v_flex() - .w_full() + div() + .id("pdf-scroll-container") + .track_scroll(&self.pdf_scroll_handle) + .overflow_y_scroll() + .size_full() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); + }), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + }), + ) + .on_scroll_wheel(cx.listener(|_this, _event: &ScrollWheelEvent, _window, cx| { + cx.notify(); + })) + .flex() + .flex_col() .items_center() .py_6() .gap_8() @@ -1215,7 +1248,8 @@ impl Render for ImageView { .cursor(gpui::CursorStyle::IBeam) .on_mouse_down( MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); this.handle_pdf_canvas_mouse_down(page_idx, event.position, event.click_count, cx); }), ) @@ -1231,6 +1265,7 @@ impl Render for ImageView { .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); this.handle_pdf_canvas_right_click(page_idx, event.position, window, cx); }), ) @@ -1312,6 +1347,14 @@ impl Render for ImageView { .color(Color::Muted) ) })) + ) + .custom_scrollbars( + Scrollbars::new(ScrollAxes::Vertical) + .id("pdf-scrollbar") + .tracked_scroll_handle(&self.pdf_scroll_handle) + .notify_content(), + window, + cx, ); if self.show_text_panel { @@ -1520,8 +1563,13 @@ impl Render for ImageViewToolbarControls { let image_item = image_view.read(cx).image_item.clone(); let (is_pdf, current_page, total_pages, show_text_panel) = { let item = image_item.read(cx); - let show_text = image_view.read(cx).show_text_panel; - (item.is_pdf(), item.current_page(), item.total_pages(), show_text) + let view = image_view.read(cx); + let show_text = view.show_text_panel; + let current_page = view + .pdf_scroll_handle + .top_item() + .min(item.total_pages().saturating_sub(1)); + (item.is_pdf(), current_page, item.total_pages(), show_text) }; h_flex() @@ -1537,6 +1585,7 @@ impl Render for ImageViewToolbarControls { .on_click(move |_, window, cx| { if let Some(view) = image_view_prev.upgrade() { view.update(cx, |this, cx| { + window.focus(&this.focus_handle, cx); this.previous_page(&PreviousPage, window, cx); }); } @@ -1555,6 +1604,7 @@ impl Render for ImageViewToolbarControls { .on_click(move |_, window, cx| { if let Some(view) = image_view_next.upgrade() { view.update(cx, |this, cx| { + window.focus(&this.focus_handle, cx); this.next_page(&NextPage, window, cx); }); } From 808b7e88d5ab3e6b25fb067d46bce8e83865b512 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 11:50:41 +0530 Subject: [PATCH 10/19] feat(pdf): add thick scrollbar and edge autoscroll text selection --- crates/image_viewer/src/image_viewer.rs | 544 ++++++++++++++++++++---- crates/project/src/image_store.rs | 10 +- crates/ui/src/components/scrollbar.rs | 8 +- 3 files changed, 465 insertions(+), 97 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 4709dccada..351c80a971 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -20,12 +20,13 @@ use gpui::{ use language::File as _; use persistence::ImageViewerDb; use project::{ - ImageItem, Project, ProjectPath, git_store::GitStoreEvent, image_store::ImageItemEvent, + ImageItem, Project, ProjectPath, git_store::GitStoreEvent, + image_store::{ImageItemEvent, PdfPageEntry, PdfPageInfo}, }; use settings::Settings; use theme_settings::ThemeSettings; use ui::{ - ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*, + ContextMenu, Divider, ScrollAxes, Scrollbars, ScrollbarStyle, Tooltip, WithScrollbar, prelude::*, }; use util::{ResultExt as _, paths::PathExt}; use workspace::{ @@ -78,12 +79,31 @@ const ZOOM_EDITOR_HORIZONTAL_PADDING: f32 = 12.0; // Extra room for cursor and e #[derive(Clone, Debug, PartialEq)] pub struct PdfSelection { - pub page_index: usize, + pub start_page: usize, pub start_seg: usize, + pub end_page: usize, pub end_seg: usize, pub text: String, } +impl PdfSelection { + pub fn min_endpoint(&self) -> (usize, usize) { + if self.start_page < self.end_page || (self.start_page == self.end_page && self.start_seg <= self.end_seg) { + (self.start_page, self.start_seg) + } else { + (self.end_page, self.end_seg) + } + } + + pub fn max_endpoint(&self) -> (usize, usize) { + if self.start_page < self.end_page || (self.start_page == self.end_page && self.start_seg <= self.end_seg) { + (self.end_page, self.end_seg) + } else { + (self.start_page, self.start_seg) + } + } +} + pub struct ImageView { image_item: Entity, project: Entity, @@ -101,6 +121,8 @@ pub struct ImageView { text_editor: Option>, pdf_selection: Option, pdf_mouse_down: bool, + pdf_drag_position: Option>, + pdf_autoscroll_task: Option>, context_menu: Option<(Entity, Point, Subscription)>, page_bounds: Rc>>>, } @@ -176,8 +198,14 @@ impl ImageView { ) -> Self { // Start loading the image to render in the background to prevent the view // from flickering in most cases. - let pending_image = image_item.read(cx).image.clone(); - let _render_image = pending_image.clone().get_render_image(window, cx); + let is_pdf = image_item.read(cx).is_pdf(); + let pending_image = if is_pdf { + None + } else { + let pending_image = image_item.read(cx).image.clone(); + let _render_image = pending_image.clone().get_render_image(window, cx); + Some(pending_image) + }; cx.subscribe(&image_item, Self::on_image_event).detach(); let git_store = project.read(cx).git_store().clone(); @@ -188,11 +216,13 @@ impl ImageView { }) .detach(); cx.on_release_in(window, |this, window, cx| { - let image_data = this.image_item.read(cx).image.clone(); - if let Some(image) = image_data.clone().get_render_image(window, cx) { - cx.drop_image(image, None); + if !this.image_item.read(cx).is_pdf() { + let image_data = this.image_item.read(cx).image.clone(); + if let Some(image) = image_data.clone().get_render_image(window, cx) { + cx.drop_image(image, None); + } + image_data.remove_asset(cx); } - image_data.remove_asset(cx); }) .detach(); @@ -210,7 +240,7 @@ impl ImageView { last_mouse_position: None, container_bounds: None, image_size, - pending_image: Some(pending_image), + pending_image, displayed_image: None, pdf_scroll_handle: gpui::ScrollHandle::new(), show_text_panel: false, @@ -218,6 +248,8 @@ impl ImageView { text_editor: None, pdf_selection: None, pdf_mouse_down: false, + pdf_drag_position: None, + pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), } @@ -361,14 +393,15 @@ impl ImageView { let target_page = self .pdf_selection .as_ref() - .map(|s| s.page_index) + .map(|s| s.start_page) .unwrap_or(cur_page); if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == target_page) { let total_segs = page.text_segments.len(); if total_segs > 0 { self.pdf_selection = Some(PdfSelection { - page_index: target_page, + start_page: target_page, start_seg: 0, + end_page: target_page, end_seg: total_segs.saturating_sub(1), text: page.page_text.clone(), }); @@ -474,6 +507,248 @@ impl ImageView { closest.map(|(idx, _)| idx) } + fn collect_selection_text( + pages: &[PdfPageEntry], + start_page: usize, + start_seg: usize, + end_page: usize, + end_seg: usize, + ) -> String { + let (min_p, min_s, max_p, max_s) = if start_page < end_page || (start_page == end_page && start_seg <= end_seg) { + (start_page, start_seg, end_page, end_seg) + } else { + (end_page, end_seg, start_page, start_seg) + }; + + let mut result_parts = Vec::new(); + for p in min_p..=max_p { + if let Some(page) = pages.iter().find(|page| page.page_index == p) { + let segs = &page.text_segments; + if segs.is_empty() { + continue; + } + let first_idx = if p == min_p { min_s.min(segs.len().saturating_sub(1)) } else { 0 }; + let last_idx = if p == max_p { max_s.min(segs.len().saturating_sub(1)) } else { segs.len().saturating_sub(1) }; + if first_idx <= last_idx { + let page_text = segs[first_idx..=last_idx] + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" "); + if !page_text.is_empty() { + result_parts.push(page_text); + } + } + } + } + result_parts.join("\n\n") + } + + fn find_page_and_segment_at( + &self, + cursor_pos: Point, + pdf_info: &PdfPageInfo, + ) -> Option<(usize, usize)> { + let page_bounds = self.page_bounds.borrow(); + if page_bounds.is_empty() { + return None; + } + + // 1. Direct hit inside page bounds + for (&page_idx, &bounds) in page_bounds.iter() { + if bounds.contains(&cursor_pos) { + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_idx) { + if page.text_segments.is_empty() { + return Some((page_idx, 0)); + } + let b_w: f32 = bounds.size.width.into(); + let b_h: f32 = bounds.size.height.into(); + let norm_x = if b_w > 0.0 { + let b_ox: f32 = bounds.origin.x.into(); + let p_x: f32 = cursor_pos.x.into(); + ((p_x - b_ox) / b_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if b_h > 0.0 { + let b_oy: f32 = bounds.origin.y.into(); + let p_y: f32 = cursor_pos.y.into(); + ((p_y - b_oy) / b_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + return Some((page_idx, seg_idx)); + } + } + } + } + + let mut sorted_pages: Vec<(usize, Bounds)> = page_bounds.iter().map(|(&idx, &b)| (idx, b)).collect(); + sorted_pages.sort_by_key(|(idx, _)| *idx); + + let first = sorted_pages.first()?; + let last = sorted_pages.last()?; + + if cursor_pos.y < first.1.origin.y { + return Some((first.0, 0)); + } + if cursor_pos.y > last.1.origin.y + last.1.size.height { + let last_page = pdf_info.pages.iter().find(|p| p.page_index == last.0)?; + let last_seg = last_page.text_segments.len().saturating_sub(1); + return Some((last.0, last_seg)); + } + + // 2. Vertical overlap + for (page_idx, bounds) in &sorted_pages { + let top = bounds.origin.y; + let bottom = bounds.origin.y + bounds.size.height; + if cursor_pos.y >= top && cursor_pos.y <= bottom { + if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == *page_idx) { + if page.text_segments.is_empty() { + return Some((*page_idx, 0)); + } + let b_w: f32 = bounds.size.width.into(); + let b_h: f32 = bounds.size.height.into(); + let norm_x = if b_w > 0.0 { + let b_ox: f32 = bounds.origin.x.into(); + let p_x: f32 = cursor_pos.x.into(); + ((p_x - b_ox) / b_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if b_h > 0.0 { + let b_oy: f32 = bounds.origin.y.into(); + let p_y: f32 = cursor_pos.y.into(); + ((p_y - b_oy) / b_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { + return Some((*page_idx, seg_idx)); + } + } + } + } + + // 3. Gap between two adjacent pages + for window in sorted_pages.windows(2) { + let (p1, b1) = window[0]; + let (p2, b2) = window[1]; + let b1_bot = b1.origin.y + b1.size.height; + let b2_top = b2.origin.y; + if cursor_pos.y >= b1_bot && cursor_pos.y <= b2_top { + if cursor_pos.y - b1_bot < b2_top - cursor_pos.y { + let page = pdf_info.pages.iter().find(|p| p.page_index == p1)?; + return Some((p1, page.text_segments.len().saturating_sub(1))); + } else { + return Some((p2, 0)); + } + } + } + + None + } + + fn update_pdf_drag_selection(&mut self, cursor_pos: Point, cx: &mut Context) { + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + let Some(ref pdf_info) = pdf_info else { return; }; + if let Some((page_idx, seg_idx)) = self.find_page_and_segment_at(cursor_pos, pdf_info) { + if let Some(ref mut sel) = self.pdf_selection { + if sel.end_page != page_idx || sel.end_seg != seg_idx { + sel.end_page = page_idx; + sel.end_seg = seg_idx; + sel.text = Self::collect_selection_text( + &pdf_info.pages, + sel.start_page, + sel.start_seg, + sel.end_page, + sel.end_seg, + ); + cx.notify(); + } + } + } + } + + fn check_pdf_autoscroll(&mut self, cx: &mut Context) { + if !self.pdf_mouse_down || self.pdf_drag_position.is_none() { + self.pdf_autoscroll_task = None; + return; + } + + if self.pdf_autoscroll_task.is_some() { + return; + } + + let task = cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(std::time::Duration::from_millis(16)).await; + + let should_continue = this.update(cx, |this, cx| { + if !this.pdf_mouse_down { + this.pdf_autoscroll_task = None; + return false; + } + let Some(cursor_pos) = this.pdf_drag_position else { + this.pdf_autoscroll_task = None; + return false; + }; + + let viewport_bounds = this.pdf_scroll_handle.bounds(); + if viewport_bounds.size.height <= px(0.) { + return false; + } + + let edge_threshold = px(70.0); + let top_threshold = viewport_bounds.top() + edge_threshold; + let bottom_threshold = viewport_bounds.bottom() - edge_threshold; + + let mut scroll_dy = px(0.); + if cursor_pos.y > bottom_threshold { + let dist: f32 = (cursor_pos.y - bottom_threshold).into(); + let speed = (dist * 0.35 + 4.0).clamp(4.0, 32.0); + scroll_dy = -px(speed); + } else if cursor_pos.y < top_threshold { + let dist: f32 = (top_threshold - cursor_pos.y).into(); + let speed = (dist * 0.35 + 4.0).clamp(4.0, 32.0); + scroll_dy = px(speed); + } + + if scroll_dy != px(0.) { + let current_offset = this.pdf_scroll_handle.offset(); + let max_offset = this.pdf_scroll_handle.max_offset(); + let new_y = (current_offset.y + scroll_dy).clamp(-max_offset.y, px(0.)); + if new_y != current_offset.y { + this.pdf_scroll_handle.set_offset(point(current_offset.x, new_y)); + this.update_pdf_drag_selection(cursor_pos, cx); + cx.notify(); + return true; + } + } + + this.pdf_autoscroll_task = None; + false + }).unwrap_or(false); + + if !should_continue { + break; + } + } + }); + + self.pdf_autoscroll_task = Some(task); + } + + fn handle_pdf_general_mouse_move(&mut self, cursor_pos: Point, cx: &mut Context) { + if !self.pdf_mouse_down { + return; + } + self.pdf_drag_position = Some(cursor_pos); + self.update_pdf_drag_selection(cursor_pos, cx); + self.check_pdf_autoscroll(cx); + } + fn handle_pdf_canvas_mouse_down( &mut self, page_index: usize, @@ -488,9 +763,12 @@ impl ImageView { if click_count >= 3 { self.pdf_mouse_down = false; + self.pdf_drag_position = None; + self.pdf_autoscroll_task = None; self.pdf_selection = Some(PdfSelection { - page_index, + start_page: page_index, start_seg: 0, + end_page: page_index, end_seg: page.text_segments.len().saturating_sub(1), text: page.page_text.clone(), }); @@ -514,75 +792,30 @@ impl ImageView { if let Some(idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { self.pdf_mouse_down = true; + self.pdf_drag_position = Some(click_pos); let seg = &page.text_segments[idx]; self.pdf_selection = Some(PdfSelection { - page_index, + start_page: page_index, start_seg: idx, + end_page: page_index, end_seg: idx, text: seg.text.clone(), }); } else { self.pdf_mouse_down = false; self.pdf_selection = None; + self.pdf_drag_position = None; + self.pdf_autoscroll_task = None; } cx.notify(); } - fn handle_pdf_canvas_mouse_move( - &mut self, - page_index: usize, - cursor_pos: Point, - cx: &mut Context, - ) { - if !self.pdf_mouse_down { - return; - } - let Some(ref sel) = self.pdf_selection else { return; }; - if sel.page_index != page_index { - return; - } - - let bounds = self.page_bounds.borrow().get(&page_index).copied(); - let Some(bounds) = bounds else { return; }; - let b_w: f32 = bounds.size.width.into(); - let b_h: f32 = bounds.size.height.into(); - if b_w <= 0.0 || b_h <= 0.0 { - return; - } - - let pdf_info = self.image_item.read(cx).pdf_info.clone(); - let Some(pdf_info) = pdf_info else { return; }; - let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) else { return; }; - - let b_ox: f32 = bounds.origin.x.into(); - let b_oy: f32 = bounds.origin.y.into(); - let p_x: f32 = cursor_pos.x.into(); - let p_y: f32 = cursor_pos.y.into(); - let norm_x = ((p_x - b_ox) / b_w).clamp(0.0, 1.0); - let norm_y = ((p_y - b_oy) / b_h).clamp(0.0, 1.0); - - if let Some(seg_index) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { - if let Some(ref mut sel) = self.pdf_selection { - if sel.end_seg != seg_index { - sel.end_seg = seg_index; - let min_idx = sel.start_seg.min(seg_index); - let max_idx = sel.start_seg.max(seg_index); - if max_idx < page.text_segments.len() { - sel.text = page.text_segments[min_idx..=max_idx] - .iter() - .map(|s| s.text.as_str()) - .collect::>() - .join(" "); - } - cx.notify(); - } - } - } - } fn handle_pdf_mouse_up(&mut self, cx: &mut Context) { if self.pdf_mouse_down { self.pdf_mouse_down = false; + self.pdf_drag_position = None; + self.pdf_autoscroll_task = None; cx.notify(); } } @@ -610,13 +843,25 @@ impl ImageView { if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { let need_select = self.pdf_selection.as_ref().map_or(true, |s| { - s.page_index != page_index - || (seg_idx < s.start_seg.min(s.end_seg) || seg_idx > s.start_seg.max(s.end_seg)) + let (min_p, min_s) = s.min_endpoint(); + let (max_p, max_s) = s.max_endpoint(); + if page_index < min_p || page_index > max_p { + true + } else if page_index == min_p && page_index == max_p { + seg_idx < min_s || seg_idx > max_s + } else if page_index == min_p { + seg_idx < min_s + } else if page_index == max_p { + seg_idx > max_s + } else { + false + } }); if need_select { self.pdf_selection = Some(PdfSelection { - page_index, + start_page: page_index, start_seg: seg_idx, + end_page: page_index, end_seg: seg_idx, text: page.text_segments[seg_idx].text.clone(), }); @@ -1049,6 +1294,8 @@ impl Item for ImageView { text_editor: None, pdf_selection: self.pdf_selection.clone(), pdf_mouse_down: false, + pdf_drag_position: None, + pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), }))) @@ -1182,6 +1429,17 @@ impl Render for ImageView { window.focus(&this.focus_handle, cx); }), ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + if this.pdf_mouse_down { + this.handle_pdf_general_mouse_move(event.position, cx); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + }), + ) .size_full() .relative() .bg(cx.theme().colors().editor_background) @@ -1195,6 +1453,15 @@ impl Render for ImageView { .id("pdf-viewport-wrapper") .relative() .size_full() + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + this.handle_pdf_general_mouse_move(event.position, cx); + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + }), + ) .child( div() .id("pdf-scroll-container") @@ -1205,8 +1472,16 @@ impl Render for ImageView { MouseButton::Left, cx.listener(|this, _event: &MouseDownEvent, window, cx| { window.focus(&this.focus_handle, cx); + this.pdf_mouse_down = false; + this.pdf_selection = None; + this.pdf_drag_position = None; + this.pdf_autoscroll_task = None; + cx.notify(); }), ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + this.handle_pdf_general_mouse_move(event.position, cx); + })) .on_mouse_up( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { @@ -1249,12 +1524,13 @@ impl Render for ImageView { .on_mouse_down( MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); window.focus(&this.focus_handle, cx); this.handle_pdf_canvas_mouse_down(page_idx, event.position, event.click_count, cx); }), ) .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { - this.handle_pdf_canvas_mouse_move(page_idx, event.position, cx); + this.handle_pdf_general_mouse_move(event.position, cx); })) .on_mouse_up( MouseButton::Left, @@ -1286,29 +1562,38 @@ impl Render for ImageView { .size_full() .absolute(), ) - .children( - pdf_selection - .as_ref() - .filter(|s| s.page_index == page_idx) - .into_iter() - .flat_map(|sel| { - let min_idx = sel.start_seg.min(sel.end_seg); - let max_idx = sel.start_seg.max(sel.end_seg); - let segs = &segments_arc; - (min_idx..=max_idx.min(segs.len().saturating_sub(1))).map(move |seg_idx| { - let seg = &segs[seg_idx]; - div() - .id(ElementId::Name(format!("pdf-sel-highlight-{page_idx}-{seg_idx}").into())) - .absolute() - .left(px(seg.x * page_w_val)) - .top(px(seg.y * page_h_val)) - .w(px(seg.width * page_w_val)) - .h(px(seg.height * page_h_val)) - .bg(gpui::rgba(0x3b82f644)) - .rounded_xs() - }) - }), - ) + .children({ + let seg_range = pdf_selection.as_ref().and_then(|sel| { + let (min_p, min_s) = sel.min_endpoint(); + let (max_p, max_s) = sel.max_endpoint(); + if page_idx < min_p || page_idx > max_p || segments_arc.is_empty() { + return None; + } + let first_idx = if page_idx == min_p { min_s.min(segments_arc.len().saturating_sub(1)) } else { 0 }; + let last_idx = if page_idx == max_p { max_s.min(segments_arc.len().saturating_sub(1)) } else { segments_arc.len().saturating_sub(1) }; + if first_idx <= last_idx { + Some(first_idx..=last_idx) + } else { + None + } + }); + let segs = segments_arc.clone(); + seg_range.into_iter().flat_map(move |range| { + let segs = segs.clone(); + range.map(move |seg_idx| { + let seg = &segs[seg_idx]; + div() + .id(ElementId::Name(format!("pdf-sel-highlight-{page_idx}-{seg_idx}").into())) + .absolute() + .left(px(seg.x * page_w_val)) + .top(px(seg.y * page_h_val)) + .w(px(seg.width * page_w_val)) + .h(px(seg.height * page_h_val)) + .bg(gpui::rgba(0x3b82f644)) + .rounded_xs() + }) + }) + }) .children(links.into_iter().enumerate().map(|(link_idx, link)| { let link_left = px(link.x * page_w_val); let link_top = px(link.y * page_h_val); @@ -1351,6 +1636,7 @@ impl Render for ImageView { .custom_scrollbars( Scrollbars::new(ScrollAxes::Vertical) .id("pdf-scrollbar") + .style(ScrollbarStyle::Thick) .tracked_scroll_handle(&self.pdf_scroll_handle) .notify_content(), window, @@ -2048,6 +2334,80 @@ mod tests { split_image_view.clone().into_any_element() }); } + + #[test] + fn test_pdf_selection_endpoints() { + let forward = PdfSelection { + start_page: 0, + start_seg: 2, + end_page: 2, + end_seg: 5, + text: String::new(), + }; + assert_eq!(forward.min_endpoint(), (0, 2)); + assert_eq!(forward.max_endpoint(), (2, 5)); + + let backward = PdfSelection { + start_page: 2, + start_seg: 5, + end_page: 0, + end_seg: 2, + text: String::new(), + }; + assert_eq!(backward.min_endpoint(), (0, 2)); + assert_eq!(backward.max_endpoint(), (2, 5)); + + let same_page_backward = PdfSelection { + start_page: 1, + start_seg: 8, + end_page: 1, + end_seg: 3, + text: String::new(), + }; + assert_eq!(same_page_backward.min_endpoint(), (1, 3)); + assert_eq!(same_page_backward.max_endpoint(), (1, 8)); + } + + #[test] + fn test_pdf_collect_selection_text_multi_page() { + use kkpdf_zed::PdfTextSegment; + + let dummy_img = Arc::new(gpui::RenderImage::new(Vec::new())); + + let pages = vec![ + PdfPageEntry { + page_index: 0, + width: 100, + height: 100, + image: dummy_img.clone(), + page_text: "Hello world".into(), + text_segments: vec![ + PdfTextSegment { text: "Hello".into(), x: 0., y: 0., width: 10., height: 10. }, + PdfTextSegment { text: "world".into(), x: 10., y: 0., width: 10., height: 10. }, + ], + links: vec![], + }, + PdfPageEntry { + page_index: 1, + width: 100, + height: 100, + image: dummy_img, + page_text: "from page two".into(), + text_segments: vec![ + PdfTextSegment { text: "from".into(), x: 0., y: 0., width: 10., height: 10. }, + PdfTextSegment { text: "page".into(), x: 10., y: 0., width: 10., height: 10. }, + PdfTextSegment { text: "two".into(), x: 20., y: 0., width: 10., height: 10. }, + ], + links: vec![], + }, + ]; + + let text_forward = ImageView::collect_selection_text(&pages, 0, 1, 1, 1); + assert_eq!(text_forward, "world\n\nfrom page"); + + let text_backward = ImageView::collect_selection_text(&pages, 1, 1, 0, 1); + assert_eq!(text_backward, "world\n\nfrom page"); + } } mod persistence { diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 89be8cb52b..b476a00295 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -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); @@ -753,7 +759,7 @@ impl RemoteImageStore { pages, full_text, }; - let dummy_img = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![])); + 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) @@ -835,7 +841,7 @@ impl ImageStoreImpl for Entity { pages, full_text, }; - let dummy_img = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![])); + 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) diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index e7177f4eca..7adc068d86 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -351,6 +351,7 @@ pub enum ScrollbarStyle { #[default] Regular, Editor, + Thick, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -374,6 +375,7 @@ impl ScrollbarStyle { match self { ScrollbarStyle::Regular => px(6.), ScrollbarStyle::Editor => px(15.), + ScrollbarStyle::Thick => px(14.), } } } @@ -1240,7 +1242,7 @@ impl Element for ScrollbarElement { bounds.size.apply_along(axis.invert(), |_| { width + match state.style { - ScrollbarStyle::Regular => 2 * SCROLLBAR_PADDING, + ScrollbarStyle::Regular | ScrollbarStyle::Thick => 2 * SCROLLBAR_PADDING, ScrollbarStyle::Editor => Pixels::ZERO, } }), @@ -1252,7 +1254,7 @@ impl Element for ScrollbarElement { // Rounded style needs a bit of padding, whereas for editor scrollbars, // we want the full length of the track let thumb_container_bounds = match state.style { - ScrollbarStyle::Regular => { + ScrollbarStyle::Regular | ScrollbarStyle::Thick => { scroll_track_bounds.dilate(-SCROLLBAR_PADDING) } ScrollbarStyle::Editor if has_border => scroll_track_bounds @@ -1488,7 +1490,7 @@ impl Element for ScrollbarElement { window.paint_quad(quad( *thumb_bounds, match style { - ScrollbarStyle::Regular => Corners::all(Pixels::MAX) + ScrollbarStyle::Regular | ScrollbarStyle::Thick => Corners::all(Pixels::MAX) .clamp_radii_for_quad_size(thumb_bounds.size), ScrollbarStyle::Editor => Corners::default(), }, From 543cc92238b371f10bcce79d3fdbb1af3cfea748 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 12:01:48 +0530 Subject: [PATCH 11/19] fix(pdf): smooth symmetric edge autoscroll and deduplicate mouse move listeners --- crates/image_viewer/src/image_viewer.rs | 128 +++++++++++++++--------- 1 file changed, 83 insertions(+), 45 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 351c80a971..5827fed1c3 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -671,6 +671,34 @@ impl ImageView { } } + pub(crate) fn calculate_pdf_autoscroll_dy( + cursor_y: Pixels, + viewport_top: Pixels, + viewport_bottom: Pixels, + ) -> Pixels { + let edge_threshold = px(96.0); + let top_threshold = viewport_top + edge_threshold; + let bottom_threshold = viewport_bottom - edge_threshold; + + if cursor_y > bottom_threshold { + let dist = f32::from(cursor_y - bottom_threshold).max(0.0); + let edge_val = f32::from(edge_threshold); + let ratio = (dist / edge_val).min(1.0); + let extra = f32::from(cursor_y - viewport_bottom).max(0.0); + let speed = 10.0 + (38.0 * ratio.powf(1.5)) + (extra * 0.4).min(24.0); + -px(speed) + } else if cursor_y < top_threshold { + let dist = f32::from(top_threshold - cursor_y).max(0.0); + let edge_val = f32::from(edge_threshold); + let ratio = (dist / edge_val).min(1.0); + let extra = f32::from(viewport_top - cursor_y).max(0.0); + let speed = 10.0 + (38.0 * ratio.powf(1.5)) + (extra * 0.4).min(24.0); + px(speed) + } else { + px(0.) + } + } + fn check_pdf_autoscroll(&mut self, cx: &mut Context) { if !self.pdf_mouse_down || self.pdf_drag_position.is_none() { self.pdf_autoscroll_task = None; @@ -697,38 +725,34 @@ impl ImageView { let viewport_bounds = this.pdf_scroll_handle.bounds(); if viewport_bounds.size.height <= px(0.) { - return false; + return true; } - let edge_threshold = px(70.0); - let top_threshold = viewport_bounds.top() + edge_threshold; - let bottom_threshold = viewport_bounds.bottom() - edge_threshold; - - let mut scroll_dy = px(0.); - if cursor_pos.y > bottom_threshold { - let dist: f32 = (cursor_pos.y - bottom_threshold).into(); - let speed = (dist * 0.35 + 4.0).clamp(4.0, 32.0); - scroll_dy = -px(speed); - } else if cursor_pos.y < top_threshold { - let dist: f32 = (top_threshold - cursor_pos.y).into(); - let speed = (dist * 0.35 + 4.0).clamp(4.0, 32.0); - scroll_dy = px(speed); - } + let scroll_dy = Self::calculate_pdf_autoscroll_dy( + cursor_pos.y, + viewport_bounds.top(), + viewport_bounds.bottom(), + ); if scroll_dy != px(0.) { let current_offset = this.pdf_scroll_handle.offset(); let max_offset = this.pdf_scroll_handle.max_offset(); let new_y = (current_offset.y + scroll_dy).clamp(-max_offset.y, px(0.)); if new_y != current_offset.y { + let dy = new_y - current_offset.y; this.pdf_scroll_handle.set_offset(point(current_offset.x, new_y)); + { + let mut bounds_map = this.page_bounds.borrow_mut(); + for bounds in bounds_map.values_mut() { + bounds.origin.y += dy; + } + } this.update_pdf_drag_selection(cursor_pos, cx); cx.notify(); - return true; } } - this.pdf_autoscroll_task = None; - false + true }).unwrap_or(false); if !should_continue { @@ -1453,15 +1477,6 @@ impl Render for ImageView { .id("pdf-viewport-wrapper") .relative() .size_full() - .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { - this.handle_pdf_general_mouse_move(event.position, cx); - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.handle_pdf_mouse_up(cx); - }), - ) .child( div() .id("pdf-scroll-container") @@ -1479,15 +1494,6 @@ impl Render for ImageView { cx.notify(); }), ) - .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { - this.handle_pdf_general_mouse_move(event.position, cx); - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.handle_pdf_mouse_up(cx); - }), - ) .on_scroll_wheel(cx.listener(|_this, _event: &ScrollWheelEvent, _window, cx| { cx.notify(); })) @@ -1529,15 +1535,6 @@ impl Render for ImageView { this.handle_pdf_canvas_mouse_down(page_idx, event.position, event.click_count, cx); }), ) - .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _window, cx| { - this.handle_pdf_general_mouse_move(event.position, cx); - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.handle_pdf_mouse_up(cx); - }), - ) .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, window, cx| { @@ -2408,6 +2405,47 @@ mod tests { let text_backward = ImageView::collect_selection_text(&pages, 1, 1, 0, 1); assert_eq!(text_backward, "world\n\nfrom page"); } + + #[test] + fn test_pdf_autoscroll_symmetry_and_speed() { + let v_top = px(100.0); + let v_bottom = px(900.0); + // Neutral zone (middle of viewport) + assert_eq!( + ImageView::calculate_pdf_autoscroll_dy(px(500.0), v_top, v_bottom), + px(0.0) + ); + + // At edge threshold (dist = 0) + let top_thresh = px(196.0); // 100 + 96 + let bot_thresh = px(804.0); // 900 - 96 + let dy_thresh_up = ImageView::calculate_pdf_autoscroll_dy(top_thresh - px(0.01), v_top, v_bottom); + let dy_thresh_down = ImageView::calculate_pdf_autoscroll_dy(bot_thresh + px(0.01), v_top, v_bottom); + assert_eq!(f32::from(dy_thresh_up).round(), 10.0); + assert_eq!(f32::from(dy_thresh_down).round(), -10.0); + + // Mid-margin (dist = 48px into margin) + let top_mid = v_top + px(48.0); + let bot_mid = v_bottom - px(48.0); + let dy_up = ImageView::calculate_pdf_autoscroll_dy(top_mid, v_top, v_bottom); + let dy_down = ImageView::calculate_pdf_autoscroll_dy(bot_mid, v_top, v_bottom); + assert_eq!(dy_up, -dy_down); + assert!(f32::from(dy_up) > 20.0); + + // At exact viewport edge (dist = 96px, ratio = 1.0) + let dy_edge_up = ImageView::calculate_pdf_autoscroll_dy(v_top, v_top, v_bottom); + let dy_edge_down = ImageView::calculate_pdf_autoscroll_dy(v_bottom, v_top, v_bottom); + assert_eq!(dy_edge_up, -dy_edge_down); + assert_eq!(dy_edge_up, px(48.0)); + assert_eq!(dy_edge_down, -px(48.0)); + + // Past viewport edge (extra = 20px) + let dy_past_up = ImageView::calculate_pdf_autoscroll_dy(v_top - px(20.0), v_top, v_bottom); + let dy_past_down = ImageView::calculate_pdf_autoscroll_dy(v_bottom + px(20.0), v_top, v_bottom); + assert_eq!(dy_past_up, -dy_past_down); + assert_eq!(dy_past_up, px(56.0)); + assert_eq!(dy_past_down, -px(56.0)); + } } mod persistence { From 1dda6d75fd9d1b2305930935aa7848c3170891ed Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 12:12:12 +0530 Subject: [PATCH 12/19] perf(pdf): zero-alloc GPU highlight quads, delta-time autoscroll, and 9500px/s edge speed --- crates/image_viewer/src/image_viewer.rs | 184 ++++++++++++++---------- 1 file changed, 111 insertions(+), 73 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 5827fed1c3..5e2b60ccd5 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -372,7 +372,25 @@ impl ImageView { cx.notify(); } + fn ensure_pdf_selection_text(&mut self, cx: &mut Context) { + if let Some(ref mut sel) = self.pdf_selection { + if sel.text.is_empty() { + let pdf_info = self.image_item.read(cx).pdf_info.clone(); + if let Some(pdf_info) = pdf_info { + sel.text = Self::collect_selection_text( + &pdf_info.pages, + sel.start_page, + sel.start_seg, + sel.end_page, + sel.end_seg, + ); + } + } + } + } + fn copy_text(&mut self, _: &CopyText, _window: &mut Window, cx: &mut Context) { + self.ensure_pdf_selection_text(cx); if let Some(ref sel) = self.pdf_selection { if !sel.text.is_empty() { cx.write_to_clipboard(gpui::ClipboardItem::new_string(sel.text.clone())); @@ -417,6 +435,7 @@ impl ImageView { window: &mut Window, cx: &mut Context, ) { + self.ensure_pdf_selection_text(cx); let has_selection = self .pdf_selection .as_ref() @@ -658,25 +677,19 @@ impl ImageView { if sel.end_page != page_idx || sel.end_seg != seg_idx { sel.end_page = page_idx; sel.end_seg = seg_idx; - sel.text = Self::collect_selection_text( - &pdf_info.pages, - sel.start_page, - sel.start_seg, - sel.end_page, - sel.end_seg, - ); + sel.text.clear(); cx.notify(); } } } } - pub(crate) fn calculate_pdf_autoscroll_dy( + pub(crate) fn calculate_pdf_autoscroll_velocity( cursor_y: Pixels, viewport_top: Pixels, viewport_bottom: Pixels, - ) -> Pixels { - let edge_threshold = px(96.0); + ) -> f32 { + let edge_threshold = px(110.0); let top_threshold = viewport_top + edge_threshold; let bottom_threshold = viewport_bottom - edge_threshold; @@ -685,17 +698,17 @@ impl ImageView { let edge_val = f32::from(edge_threshold); let ratio = (dist / edge_val).min(1.0); let extra = f32::from(cursor_y - viewport_bottom).max(0.0); - let speed = 10.0 + (38.0 * ratio.powf(1.5)) + (extra * 0.4).min(24.0); - -px(speed) + let speed = 700.0 + (3800.0 * ratio.powf(1.6)) + (extra * 50.0).min(5000.0); + -speed } else if cursor_y < top_threshold { let dist = f32::from(top_threshold - cursor_y).max(0.0); let edge_val = f32::from(edge_threshold); let ratio = (dist / edge_val).min(1.0); let extra = f32::from(viewport_top - cursor_y).max(0.0); - let speed = 10.0 + (38.0 * ratio.powf(1.5)) + (extra * 0.4).min(24.0); - px(speed) + let speed = 700.0 + (3800.0 * ratio.powf(1.6)) + (extra * 50.0).min(5000.0); + speed } else { - px(0.) + 0.0 } } @@ -710,8 +723,12 @@ impl ImageView { } let task = cx.spawn(async move |this, cx| { + let mut last_tick = std::time::Instant::now(); loop { - cx.background_executor().timer(std::time::Duration::from_millis(16)).await; + cx.background_executor().timer(std::time::Duration::from_millis(8)).await; + let now = std::time::Instant::now(); + let dt = (now - last_tick).as_secs_f32().min(0.05); + last_tick = now; let should_continue = this.update(cx, |this, cx| { if !this.pdf_mouse_down { @@ -728,13 +745,14 @@ impl ImageView { return true; } - let scroll_dy = Self::calculate_pdf_autoscroll_dy( + let velocity = Self::calculate_pdf_autoscroll_velocity( cursor_pos.y, viewport_bounds.top(), viewport_bounds.bottom(), ); - if scroll_dy != px(0.) { + if velocity != 0.0 { + let scroll_dy = px(velocity * dt); let current_offset = this.pdf_scroll_handle.offset(); let max_offset = this.pdf_scroll_handle.max_offset(); let new_y = (current_offset.y + scroll_dy).clamp(-max_offset.y, px(0.)); @@ -840,6 +858,7 @@ impl ImageView { self.pdf_mouse_down = false; self.pdf_drag_position = None; self.pdf_autoscroll_task = None; + self.ensure_pdf_selection_text(cx); cx.notify(); } } @@ -1464,6 +1483,12 @@ impl Render for ImageView { this.handle_pdf_mouse_up(cx); }), ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.handle_pdf_mouse_up(cx); + }), + ) .size_full() .relative() .bg(cx.theme().colors().editor_background) @@ -1542,24 +1567,14 @@ impl Render for ImageView { this.handle_pdf_canvas_right_click(page_idx, event.position, window, cx); }), ) - .child({ - let page_bounds = self.page_bounds.clone(); - canvas( - move |bounds: Bounds, _window: &mut Window, _cx: &mut App| { - page_bounds.borrow_mut().insert(page_idx, bounds); - }, - |_bounds: Bounds, _state: (), _window: &mut Window, _cx: &mut App| {}, - ) - .absolute() - .size_full() - }) .child( img(page.image) .id(("pdf-page-img", page_idx)) .size_full() .absolute(), ) - .children({ + .child({ + let page_bounds = self.page_bounds.clone(); let seg_range = pdf_selection.as_ref().and_then(|sel| { let (min_p, min_s) = sel.min_endpoint(); let (max_p, max_s) = sel.max_endpoint(); @@ -1569,27 +1584,43 @@ impl Render for ImageView { let first_idx = if page_idx == min_p { min_s.min(segments_arc.len().saturating_sub(1)) } else { 0 }; let last_idx = if page_idx == max_p { max_s.min(segments_arc.len().saturating_sub(1)) } else { segments_arc.len().saturating_sub(1) }; if first_idx <= last_idx { - Some(first_idx..=last_idx) + Some((first_idx, last_idx)) } else { None } }); let segs = segments_arc.clone(); - seg_range.into_iter().flat_map(move |range| { - let segs = segs.clone(); - range.map(move |seg_idx| { - let seg = &segs[seg_idx]; - div() - .id(ElementId::Name(format!("pdf-sel-highlight-{page_idx}-{seg_idx}").into())) - .absolute() - .left(px(seg.x * page_w_val)) - .top(px(seg.y * page_h_val)) - .w(px(seg.width * page_w_val)) - .h(px(seg.height * page_h_val)) - .bg(gpui::rgba(0x3b82f644)) - .rounded_xs() - }) - }) + canvas( + move |bounds: Bounds, _window: &mut Window, _cx: &mut App| { + page_bounds.borrow_mut().insert(page_idx, bounds); + }, + move |bounds: Bounds, _state: (), window: &mut Window, _cx: &mut App| { + if let Some((first_idx, last_idx)) = seg_range { + let highlight_color = gpui::rgba(0x3b82f644); + let b_ox = bounds.origin.x; + let b_oy = bounds.origin.y; + let b_w = f32::from(bounds.size.width); + let b_h = f32::from(bounds.size.height); + for seg_idx in first_idx..=last_idx { + if let Some(seg) = segs.get(seg_idx) { + let quad_bounds = Bounds { + origin: point( + b_ox + px(seg.x * b_w), + b_oy + px(seg.y * b_h), + ), + size: size( + px(seg.width * b_w), + px(seg.height * b_h), + ), + }; + window.paint_quad(gpui::fill(quad_bounds, highlight_color).corner_radii(px(2.0))); + } + } + } + }, + ) + .absolute() + .size_full() }) .children(links.into_iter().enumerate().map(|(link_idx, link)| { let link_left = px(link.x * page_w_val); @@ -2412,39 +2443,46 @@ mod tests { let v_bottom = px(900.0); // Neutral zone (middle of viewport) assert_eq!( - ImageView::calculate_pdf_autoscroll_dy(px(500.0), v_top, v_bottom), - px(0.0) + ImageView::calculate_pdf_autoscroll_velocity(px(500.0), v_top, v_bottom), + 0.0 ); // At edge threshold (dist = 0) - let top_thresh = px(196.0); // 100 + 96 - let bot_thresh = px(804.0); // 900 - 96 - let dy_thresh_up = ImageView::calculate_pdf_autoscroll_dy(top_thresh - px(0.01), v_top, v_bottom); - let dy_thresh_down = ImageView::calculate_pdf_autoscroll_dy(bot_thresh + px(0.01), v_top, v_bottom); - assert_eq!(f32::from(dy_thresh_up).round(), 10.0); - assert_eq!(f32::from(dy_thresh_down).round(), -10.0); + let top_thresh = px(210.0); // 100 + 110 + let bot_thresh = px(790.0); // 900 - 110 + let v_thresh_up = ImageView::calculate_pdf_autoscroll_velocity(top_thresh - px(0.01), v_top, v_bottom); + let v_thresh_down = ImageView::calculate_pdf_autoscroll_velocity(bot_thresh + px(0.01), v_top, v_bottom); + assert_eq!(v_thresh_up.round(), 700.0); + assert_eq!(v_thresh_down.round(), -700.0); - // Mid-margin (dist = 48px into margin) - let top_mid = v_top + px(48.0); - let bot_mid = v_bottom - px(48.0); - let dy_up = ImageView::calculate_pdf_autoscroll_dy(top_mid, v_top, v_bottom); - let dy_down = ImageView::calculate_pdf_autoscroll_dy(bot_mid, v_top, v_bottom); - assert_eq!(dy_up, -dy_down); - assert!(f32::from(dy_up) > 20.0); + // Mid-margin (dist = 55px into margin) + let top_mid = v_top + px(55.0); + let bot_mid = v_bottom - px(55.0); + let v_up = ImageView::calculate_pdf_autoscroll_velocity(top_mid, v_top, v_bottom); + let v_down = ImageView::calculate_pdf_autoscroll_velocity(bot_mid, v_top, v_bottom); + assert_eq!(v_up, -v_down); + assert!(v_up > 1500.0); - // At exact viewport edge (dist = 96px, ratio = 1.0) - let dy_edge_up = ImageView::calculate_pdf_autoscroll_dy(v_top, v_top, v_bottom); - let dy_edge_down = ImageView::calculate_pdf_autoscroll_dy(v_bottom, v_top, v_bottom); - assert_eq!(dy_edge_up, -dy_edge_down); - assert_eq!(dy_edge_up, px(48.0)); - assert_eq!(dy_edge_down, -px(48.0)); + // At exact viewport edge (dist = 110px, ratio = 1.0) + let v_edge_up = ImageView::calculate_pdf_autoscroll_velocity(v_top, v_top, v_bottom); + let v_edge_down = ImageView::calculate_pdf_autoscroll_velocity(v_bottom, v_top, v_bottom); + assert_eq!(v_edge_up, -v_edge_down); + assert_eq!(v_edge_up, 4500.0); + assert_eq!(v_edge_down, -4500.0); - // Past viewport edge (extra = 20px) - let dy_past_up = ImageView::calculate_pdf_autoscroll_dy(v_top - px(20.0), v_top, v_bottom); - let dy_past_down = ImageView::calculate_pdf_autoscroll_dy(v_bottom + px(20.0), v_top, v_bottom); - assert_eq!(dy_past_up, -dy_past_down); - assert_eq!(dy_past_up, px(56.0)); - assert_eq!(dy_past_down, -px(56.0)); + // Past viewport edge (extra = 20px -> +1000.0 speed) + let v_past_up = ImageView::calculate_pdf_autoscroll_velocity(v_top - px(20.0), v_top, v_bottom); + let v_past_down = ImageView::calculate_pdf_autoscroll_velocity(v_bottom + px(20.0), v_top, v_bottom); + assert_eq!(v_past_up, -v_past_down); + assert_eq!(v_past_up, 5500.0); + assert_eq!(v_past_down, -5500.0); + + // Far past viewport edge (extra = 150px -> capped at 9500.0 speed) + let v_far_up = ImageView::calculate_pdf_autoscroll_velocity(v_top - px(150.0), v_top, v_bottom); + let v_far_down = ImageView::calculate_pdf_autoscroll_velocity(v_bottom + px(150.0), v_top, v_bottom); + assert_eq!(v_far_up, -v_far_down); + assert_eq!(v_far_up, 9500.0); + assert_eq!(v_far_down, -9500.0); } } From ac94de78ab696fc897b82de467490c1a57e72a6d Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 12:25:44 +0530 Subject: [PATCH 13/19] feat(pdf): vendor in-tree kkpdf_zed engine and harden PDF security whitelist --- Cargo.lock | 29 +- Cargo.toml | 4 +- crates/image_viewer/src/image_viewer.rs | 33 +- crates/kkpdf_zed/Cargo.toml | 21 + crates/kkpdf_zed/README.md | 100 +++ crates/kkpdf_zed/src/cache.rs | 303 +++++++ crates/kkpdf_zed/src/document.rs | 198 +++++ crates/kkpdf_zed/src/lib.rs | 31 + crates/kkpdf_zed/src/pdfium.rs | 927 ++++++++++++++++++++ crates/kkpdf_zed/src/rasterizer.rs | 205 +++++ crates/kkpdf_zed/src/settings.rs | 75 ++ crates/kkpdf_zed/src/ui/mod.rs | 5 + crates/kkpdf_zed/src/ui/toolbar.rs | 102 +++ crates/kkpdf_zed/src/view.rs | 483 ++++++++++ crates/kkpdf_zed/src/watcher.rs | 175 ++++ crates/kkpdf_zed/tests/integration_tests.rs | 107 +++ 16 files changed, 2770 insertions(+), 28 deletions(-) create mode 100644 crates/kkpdf_zed/Cargo.toml create mode 100644 crates/kkpdf_zed/README.md create mode 100644 crates/kkpdf_zed/src/cache.rs create mode 100644 crates/kkpdf_zed/src/document.rs create mode 100644 crates/kkpdf_zed/src/lib.rs create mode 100644 crates/kkpdf_zed/src/pdfium.rs create mode 100644 crates/kkpdf_zed/src/rasterizer.rs create mode 100644 crates/kkpdf_zed/src/settings.rs create mode 100644 crates/kkpdf_zed/src/ui/mod.rs create mode 100644 crates/kkpdf_zed/src/ui/toolbar.rs create mode 100644 crates/kkpdf_zed/src/view.rs create mode 100644 crates/kkpdf_zed/src/watcher.rs create mode 100644 crates/kkpdf_zed/tests/integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index b81ceac6f7..c4a15a9ca3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9454,8 +9454,9 @@ dependencies = [ "log", "parking_lot", "pdfium-render", - "schemars 0.8.22", "serde", + "serde_json", + "tempfile", ] [[package]] @@ -15941,18 +15942,6 @@ dependencies = [ "theme_settings", ] -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "schemars_derive 0.8.22", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "0.9.0" @@ -15974,23 +15963,11 @@ dependencies = [ "dyn-clone", "indexmap 2.14.0", "ref-cast", - "schemars_derive 1.0.4", + "schemars_derive", "serde", "serde_json", ] -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - [[package]] name = "schemars_derive" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index e19280484f..ac9143ebdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,7 @@ members = [ "crates/journal", "crates/json_schema_store", "crates/keymap_editor", + "crates/kkpdf_zed", "crates/language", "crates/language_core", "crates/language_detection", @@ -376,7 +377,7 @@ http_client_tls = { path = "crates/http_client_tls" } http_proxy = { path = "crates/http_proxy" } icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } -kkpdf-zed = { path = "../../kkpdf-zed" } +kkpdf-zed = { path = "crates/kkpdf_zed" } input_latency_ui = { path = "crates/input_latency_ui" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } @@ -740,6 +741,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/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 5e2b60ccd5..186e0e6b25 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -684,6 +684,16 @@ impl ImageView { } } + /// Validates whether a URI embedded within a PDF is safe to trigger via external browser/handler. + /// Whitelists standard, safe URI schemes (https, http, mailto) to prevent + /// local file leakage (file://), arbitrary command execution, or malicious protocol handler invocation. + pub(crate) fn is_safe_pdf_url(url: &str) -> bool { + let lower = url.trim().to_ascii_lowercase(); + lower.starts_with("https://") + || lower.starts_with("http://") + || lower.starts_with("mailto:") + } + pub(crate) fn calculate_pdf_autoscroll_velocity( cursor_y: Pixels, viewport_top: Pixels, @@ -1648,7 +1658,11 @@ impl Render for ImageView { .on_mouse_down( MouseButton::Left, move |_event: &MouseDownEvent, _window, cx| { - cx.open_url(&link_url); + if Self::is_safe_pdf_url(&link_url) { + cx.open_url(&link_url); + } else { + log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url); + } cx.stop_propagation(); }, ) @@ -2484,6 +2498,23 @@ mod tests { assert_eq!(v_far_up, 9500.0); assert_eq!(v_far_down, -9500.0); } + + #[test] + fn test_pdf_safe_url_whitelist() { + // Allowed safe schemes + assert!(ImageView::is_safe_pdf_url("https://zed.dev")); + assert!(ImageView::is_safe_pdf_url("http://example.com/path")); + assert!(ImageView::is_safe_pdf_url("mailto:support@zed.dev")); + assert!(ImageView::is_safe_pdf_url(" HTTPS://DOCS.RS ")); + + // Dangerous / blocked schemes + assert!(!ImageView::is_safe_pdf_url("file:///etc/passwd")); + assert!(!ImageView::is_safe_pdf_url("javascript:alert(1)")); + assert!(!ImageView::is_safe_pdf_url("data:text/html,")); + assert!(!ImageView::is_safe_pdf_url("shell:launch")); + assert!(!ImageView::is_safe_pdf_url("powershell:something")); + assert!(!ImageView::is_safe_pdf_url("ms-msdt:/id DevDiagnostic")); + } } mod persistence { diff --git a/crates/kkpdf_zed/Cargo.toml b/crates/kkpdf_zed/Cargo.toml new file mode 100644 index 0000000000..1213944b47 --- /dev/null +++ b/crates/kkpdf_zed/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "kkpdf-zed" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[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 diff --git a/crates/kkpdf_zed/README.md b/crates/kkpdf_zed/README.md new file mode 100644 index 0000000000..d451785840 --- /dev/null +++ b/crates/kkpdf_zed/README.md @@ -0,0 +1,100 @@ +# `kkpdf-zed`: Native PDF Viewer for Zed Editor + +A native, high-performance PDF viewing engine and GPUI workspace item engineered for the [Zed Editor](https://zed.dev). + +--- + +## 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. `kkpdf-zed` is designed as a native crate to be registered within the Zed workspace (`crates/kkpdf_viewer` or integrated upstream into `zed-industries/zed`). + +`extension.toml` in this repository is a **documentation stub** preserving metadata identity for a potential future extension surface or upstream contribution. + +--- + +## 1. Core Architectural Pillars + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ KKPDF-ZED 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/page.rs`)] + │ + (Inverts White/Black; Preserves Images) + │ + ▼ + [Native GPUI Canvas / Bitmap Painter (`view.rs`)] +``` + +1. **Zero UI-Thread Blocking (120 FPS Target)**: 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), `kkpdf-zed` 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 (v1) + +- **Bitmap-Rendered Continuous Scroll**: Version 1 is engineered strictly as a high-fidelity, high-frame-rate read-only document viewer. +- **Excluded from v1**: + - Text selection and copying. + - In-document text search (`Ctrl+F`). + - Interactive PDF form field editing. +- *Rationale*: Delivering an ultra-fast, stutter-free viewing experience with zero UI locks takes precedence. Text extraction and selection layer synchronization will be introduced in subsequent milestones. + +--- + +## 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 in v1**: Because Pdfium runs in-process without sandboxing (e.g., 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 + +``` +kkpdf-zed/ +├── Cargo.toml # Native crate manifest +├── extension.toml # Documentation stub +├── README.md # Architecture & operational guide +└── src/ + ├── lib.rs # Crate entrypoint & exports + ├── document.rs # Thread-safe PdfDocument handle + ├── rasterizer.rs # Async background rendering pipeline + ├── cache.rs # Memory-budgeted LRU cache with eviction + ├── watcher.rs # Live-reload file watcher & scroll state + ├── view.rs # GPUI Render & Workspace Item implementation + └── ui/ + ├── page.rs # Bitmap painting & luminosity recolor + └── toolbar.rs # Zoom, fit-width & page jump controls +``` + +--- + +## 5. Local Development & Testing + +Run unit and integration tests across the caching and rasterization engines: + +```bash +cargo test +``` diff --git a/crates/kkpdf_zed/src/cache.rs b/crates/kkpdf_zed/src/cache.rs new file mode 100644 index 0000000000..27d1508668 --- /dev/null +++ b/crates/kkpdf_zed/src/cache.rs @@ -0,0 +1,303 @@ +//! 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>, +} + +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, + ) -> 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 + } +} + +/// A Least-Recently-Used (LRU) cache bounded by byte size and item limits. +#[derive(Debug)] +pub struct PageLruCache { + max_memory_bytes: usize, + current_memory_bytes: usize, + max_pages: Option, + entries: HashMap, + lru_order: VecDeque, +} + +impl PageLruCache { + /// 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 { + 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: RenderedPage) { + 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 + } + + /// 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"); + } +} diff --git a/crates/kkpdf_zed/src/document.rs b/crates/kkpdf_zed/src/document.rs new file mode 100644 index 0000000000..a30784f0a2 --- /dev/null +++ b/crates/kkpdf_zed/src/document.rs @@ -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, + pages: Arc>, + title: Option, +} + +impl PdfDocument { + /// Constructs a PdfDocument with explicit page dimensions. + pub fn new(path: Option, pages: Vec, title: Option) -> 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 { + 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 { + 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) -> Result { + // 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()); + } +} diff --git a/crates/kkpdf_zed/src/lib.rs b/crates/kkpdf_zed/src/lib.rs new file mode 100644 index 0000000000..530696b8bb --- /dev/null +++ b/crates/kkpdf_zed/src/lib.rs @@ -0,0 +1,31 @@ +//! # `kkpdf-zed` +//! +//! High-performance native PDF viewing engine and GPUI workspace item for Zed Editor. +//! +//! Features: +//! - Sub-millisecond 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, 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}; diff --git a/crates/kkpdf_zed/src/pdfium.rs b/crates/kkpdf_zed/src/pdfium.rs new file mode 100644 index 0000000000..66e7233daf --- /dev/null +++ b/crates/kkpdf_zed/src/pdfium.rs @@ -0,0 +1,927 @@ +//! 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); + +// 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>> = 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; + +/// Thread-safe wrapper around a Pdfium instance. +#[derive(Clone)] +pub struct PdfiumEngine { + inner: Arc>, +} + +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 { + // 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 = 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 { + 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, + ) -> Result { + 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 { + 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 = + (page.width().value * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + let target_height = + (page.height().value * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + + 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 { + 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 = + (page.width().value * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + let target_height = + (page.height().value * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + + 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::() + + ((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::() + + ((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, + ) -> Result { + 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, + pub text_segments: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct PdfDocumentDetails { + pub total_pages: usize, + pub full_text: String, + pub pages: Vec, +} + +#[derive(Debug, Clone)] +pub struct PdfPageRenderResult { + pub page_index: usize, + pub width: u32, + pub height: u32, + pub rgba_buffer: Vec, + pub text: String, + pub text_segments: Vec, + pub links: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct PdfDocumentRenderResult { + pub total_pages: usize, + pub full_text: String, + pub pages: Vec, +} + +fn extract_page_text_and_links( + page: &pdfium_render::prelude::PdfPage, + idx: usize, + full_text: &mut String, +) -> (String, Vec, Vec) { + 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 = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + + 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 = if page_w > 0.0 { (min_left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_y = if page_h > 0.0 { ((page_h - max_top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_w = if page_w > 0.0 { ((max_right - min_left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_h = if page_h > 0.0 { ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + + 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 = if page_w > 0.0 { (min_left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_y = if page_h > 0.0 { ((page_h - max_top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_w = if page_w > 0.0 { ((max_right - min_left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_h = if page_h > 0.0 { ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + + 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 = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + + 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(action) = link.action() { + if let PdfAction::Uri(uri_action) = 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 = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + + 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(action) = link.action() { + if let PdfAction::Uri(uri_action) = 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 = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + + 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 { + 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 { + 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 = + (page_w * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + let target_height = + (page_h * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + + 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); + } +} diff --git a/crates/kkpdf_zed/src/rasterizer.rs b/crates/kkpdf_zed/src/rasterizer.rs new file mode 100644 index 0000000000..db3e21683a --- /dev/null +++ b/crates/kkpdf_zed/src/rasterizer.rs @@ -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 { + 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); + } +} diff --git a/crates/kkpdf_zed/src/settings.rs b/crates/kkpdf_zed/src/settings.rs new file mode 100644 index 0000000000..996364fa9e --- /dev/null +++ b/crates/kkpdf_zed/src/settings.rs @@ -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")); + } +} diff --git a/crates/kkpdf_zed/src/ui/mod.rs b/crates/kkpdf_zed/src/ui/mod.rs new file mode 100644 index 0000000000..61a506dacd --- /dev/null +++ b/crates/kkpdf_zed/src/ui/mod.rs @@ -0,0 +1,5 @@ +//! User interface components for PDF viewing. + +pub mod toolbar; + +pub use toolbar::{PdfToolbarAction, PdfToolbarState}; diff --git a/crates/kkpdf_zed/src/ui/toolbar.rs b/crates/kkpdf_zed/src/ui/toolbar.rs new file mode 100644 index 0000000000..d6f40af2c0 --- /dev/null +++ b/crates/kkpdf_zed/src/ui/toolbar.rs @@ -0,0 +1,102 @@ +//! PDF viewer toolbar control models and layout definitions. +//! +//! 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); + } +} diff --git a/crates/kkpdf_zed/src/view.rs b/crates/kkpdf_zed/src/view.rs new file mode 100644 index 0000000000..cd38fee189 --- /dev/null +++ b/crates/kkpdf_zed/src/view.rs @@ -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, + pub(crate) engine: PdfiumEngine, + pub(crate) cache: PageLruCache, + pub(crate) settings: PdfViewerSettings, + pub(crate) raw_bytes: Option>>, + 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, +} + +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, path: Option) -> 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 { + 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 { + 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()); + } +} diff --git a/crates/kkpdf_zed/src/watcher.rs b/crates/kkpdf_zed/src/watcher.rs new file mode 100644 index 0000000000..1262708ec0 --- /dev/null +++ b/crates/kkpdf_zed/src/watcher.rs @@ -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, + last_file_mtime: Option, + is_reloading: Arc, +} + +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" + ); + } +} diff --git a/crates/kkpdf_zed/tests/integration_tests.rs b/crates/kkpdf_zed/tests/integration_tests.rs new file mode 100644 index 0000000000..c275bfca59 --- /dev/null +++ b/crates/kkpdf_zed/tests/integration_tests.rs @@ -0,0 +1,107 @@ +use kkpdf_zed::{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); +} From 962531fe8e47712689fc012993d9310eab19afdf Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 12:32:40 +0530 Subject: [PATCH 14/19] style(pdfium): fix rustfmt formatting and clippy collapsible matches --- crates/kkpdf_zed/src/pdfium.rs | 261 +++++++++++++++++++++++---------- 1 file changed, 184 insertions(+), 77 deletions(-) diff --git a/crates/kkpdf_zed/src/pdfium.rs b/crates/kkpdf_zed/src/pdfium.rs index 66e7233daf..3ac0690f6c 100644 --- a/crates/kkpdf_zed/src/pdfium.rs +++ b/crates/kkpdf_zed/src/pdfium.rs @@ -62,7 +62,10 @@ impl PdfiumEngine { 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); + log::info!( + "Successfully bound to Pdfium from environment at {}", + env_path + ); return Some(Pdfium::new(bindings)); } } @@ -85,7 +88,9 @@ impl PdfiumEngine { 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.push(PathBuf::from(format!( + "{home_dir}/.local/lib/libpdfium.dylib" + ))); } } @@ -312,13 +317,17 @@ impl PdfiumEngine { 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)); + 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 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())); } @@ -505,10 +514,26 @@ fn extract_page_text_and_links( let bottom = bounds.bottom().value.min(bounds.top().value); let top = bounds.bottom().value.max(bounds.top().value); - let norm_x = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_x = if page_w > 0.0 { + (left / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if page_h > 0.0 { + ((page_h - top) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_w = if page_w > 0.0 { + ((right - left) / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_h = if page_h > 0.0 { + ((top - bottom) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; if norm_w > 0.0 && norm_h > 0.0 { segments.push(PdfTextSegment { @@ -536,10 +561,26 @@ fn extract_page_text_and_links( if is_ws { if has_word_char && !word_text.is_empty() { - let norm_x = if page_w > 0.0 { (min_left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_y = if page_h > 0.0 { ((page_h - max_top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_w = if page_w > 0.0 { ((max_right - min_left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_h = if page_h > 0.0 { ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_x = if page_w > 0.0 { + (min_left / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if page_h > 0.0 { + ((page_h - max_top) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_w = if page_w > 0.0 { + ((max_right - min_left) / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_h = if page_h > 0.0 { + ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; if norm_w > 0.0 && norm_h > 0.0 { segments.push(PdfTextSegment { @@ -575,10 +616,26 @@ fn extract_page_text_and_links( } if has_word_char && !word_text.is_empty() { - let norm_x = if page_w > 0.0 { (min_left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_y = if page_h > 0.0 { ((page_h - max_top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_w = if page_w > 0.0 { ((max_right - min_left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_h = if page_h > 0.0 { ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_x = if page_w > 0.0 { + (min_left / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if page_h > 0.0 { + ((page_h - max_top) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_w = if page_w > 0.0 { + ((max_right - min_left) / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_h = if page_h > 0.0 { + ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; if norm_w > 0.0 && norm_h > 0.0 { segments.push(PdfTextSegment { @@ -597,10 +654,26 @@ fn extract_page_text_and_links( let bottom = bounds.bottom().value.min(bounds.top().value); let top = bounds.bottom().value.max(bounds.top().value); - let norm_x = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_x = if page_w > 0.0 { + (left / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if page_h > 0.0 { + ((page_h - top) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_w = if page_w > 0.0 { + ((right - left) / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_h = if page_h > 0.0 { + ((top - bottom) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; segments.push(PdfTextSegment { text: seg_trimmed.to_string(), @@ -615,30 +688,44 @@ fn extract_page_text_and_links( // 2. Links from page.links() for link in page.links().iter() { - if let Some(action) = link.action() { - if let PdfAction::Uri(uri_action) = 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); + 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 = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_x = if page_w > 0.0 { + (left / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if page_h > 0.0 { + ((page_h - top) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_w = if page_w > 0.0 { + ((right - left) / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_h = if page_h > 0.0 { + ((top - bottom) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; - links.push(PdfLinkAnnotation { - url: url_clean, - x: norm_x, - y: norm_y, - width: norm_w, - height: norm_h, - }); - } + links.push(PdfLinkAnnotation { + url: url_clean, + x: norm_x, + y: norm_y, + width: norm_w, + height: norm_h, + }); } } } @@ -649,32 +736,50 @@ fn extract_page_text_and_links( 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(action) = link.action() { - if let PdfAction::Uri(uri_action) = 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); + 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 = if page_w > 0.0 { (left / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_y = if page_h > 0.0 { ((page_h - top) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_w = if page_w > 0.0 { ((right - left) / page_w).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let norm_h = if page_h > 0.0 { ((top - bottom) / page_h).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let norm_x = if page_w > 0.0 { + (left / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if page_h > 0.0 { + ((page_h - top) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_w = if page_w > 0.0 { + ((right - left) / page_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_h = if page_h > 0.0 { + ((top - bottom) / page_h).clamp(0.0, 1.0) + } else { + 0.0 + }; - 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, - }); - } + 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, + }); } } } @@ -693,7 +798,9 @@ fn extract_page_text_and_links( } else { text.to_string() }; - let exists = links.iter().any(|l| (l.x - seg.x).abs() < 0.02 && (l.y - seg.y).abs() < 0.02); + 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, @@ -724,7 +831,8 @@ impl PdfiumEngine { 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); + let (page_text, segments, links) = + extract_page_text_and_links(&page, idx, &mut full_text); pages_details.push(PdfPageDetails { page_index: idx, @@ -766,14 +874,12 @@ impl PdfiumEngine { let page_w = page.width().value; let page_h = page.height().value; - let target_width = - (page_w * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; - let target_height = - (page_h * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + let target_width = (page_w * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + let target_height = (page_h * (options.target_dpi / 72.0) * options.zoom_factor) + .round() + .clamp(1.0, MAX_PAGE_DIMENSION) as i32; let render_config = PdfRenderConfig::new() .set_target_width(target_width) @@ -791,7 +897,8 @@ impl PdfiumEngine { LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold); } - let (text, text_segments, links) = extract_page_text_and_links(&page, idx, &mut full_text); + let (text, text_segments, links) = + extract_page_text_and_links(&page, idx, &mut full_text); rendered_pages.push(PdfPageRenderResult { page_index: idx, From 6ff96c4970bc72f8183aeb229eb5bcd41d88e0a7 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 12:57:25 +0530 Subject: [PATCH 15/19] perf(image_viewer): remove scroll-wheel notify spam and string-alloc link IDs --- crates/image_viewer/src/image_viewer.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 186e0e6b25..a2b034e730 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1529,9 +1529,6 @@ impl Render for ImageView { cx.notify(); }), ) - .on_scroll_wheel(cx.listener(|_this, _event: &ScrollWheelEvent, _window, cx| { - cx.notify(); - })) .flex() .flex_col() .items_center() @@ -1641,7 +1638,7 @@ impl Render for ImageView { let link_url_tooltip = link.url.clone(); div() - .id(ElementId::Name(format!("pdf-link-{page_idx}-{link_idx}").into())) + .id(("pdf-link", page_idx * 100_000 + link_idx)) .absolute() .left(link_left) .top(link_top) From 15ff33bf545cc7ae5263d1b7d3ee841d97545308 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 20:22:38 +0530 Subject: [PATCH 16/19] fix(image_viewer): address review bugs and restore plain image fit to view --- crates/image_viewer/src/image_viewer.rs | 93 +++++++++++++++++-------- crates/kkpdf_zed/src/pdfium.rs | 75 ++++++++++++++------ 2 files changed, 117 insertions(+), 51 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index a2b034e730..124ed5d643 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -181,6 +181,7 @@ impl ImageView { previous.drop_atlas_entry(window); } else { previous.release(window, cx); + self.page_bounds.borrow_mut().clear(); } } @@ -270,10 +271,14 @@ impl ImageView { .read(cx) .image_metadata .map(|m| (m.width, m.height)); + self.page_bounds.borrow_mut().clear(); cx.emit(ImageViewEvent::TitleChanged); cx.notify(); } - ImageItemEvent::ReloadNeeded => {} + ImageItemEvent::ReloadNeeded => { + self.page_bounds.borrow_mut().clear(); + cx.notify(); + } } } @@ -375,8 +380,8 @@ impl ImageView { fn ensure_pdf_selection_text(&mut self, cx: &mut Context) { if let Some(ref mut sel) = self.pdf_selection { if sel.text.is_empty() { - let pdf_info = self.image_item.read(cx).pdf_info.clone(); - if let Some(pdf_info) = pdf_info { + let item = self.image_item.read(cx); + if let Some(ref pdf_info) = item.pdf_info { sel.text = Self::collect_selection_text( &pdf_info.pages, sel.start_page, @@ -394,19 +399,13 @@ impl ImageView { if let Some(ref sel) = self.pdf_selection { if !sel.text.is_empty() { cx.write_to_clipboard(gpui::ClipboardItem::new_string(sel.text.clone())); - return; - } - } - if let Some(text) = self.image_item.read(cx).extract_text() { - if !text.is_empty() { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); } } } fn select_all(&mut self, _: &SelectAll, _window: &mut Window, cx: &mut Context) { - let pdf_info = self.image_item.read(cx).pdf_info.clone(); - if let Some(pdf_info) = pdf_info { + let item = self.image_item.read(cx); + if let Some(ref pdf_info) = item.pdf_info { let cur_page = self.pdf_scroll_handle.top_item(); let target_page = self .pdf_selection @@ -441,12 +440,7 @@ impl ImageView { .as_ref() .is_some_and(|s| !s.text.is_empty()); let is_pdf = self.image_item.read(cx).is_pdf(); - let has_doc_text = self - .image_item - .read(cx) - .extract_text() - .is_some_and(|t| !t.is_empty()); - let can_copy = has_selection || has_doc_text; + let can_copy = has_selection; let focus_handle = self.focus_handle.clone(); let context_menu = ContextMenu::build(window, cx, move |menu, _, _| { @@ -496,7 +490,8 @@ impl ImageView { } } - // 2. Line-level hit with horizontal proximity + // 2. Line-level hit with horizontal proximity threshold + const MAX_LINE_PROXIMITY: f32 = 0.05; let mut closest_on_line: Option<(usize, f32)> = None; for (i, seg) in segments.iter().enumerate() { let line_top = seg.y - seg.height * 0.2; @@ -504,7 +499,8 @@ impl ImageView { if norm_y >= line_top && norm_y <= line_bottom { let seg_center_x = seg.x + seg.width * 0.5; let dist_x = (norm_x - seg_center_x).abs(); - if closest_on_line.as_ref().map_or(true, |(_, d)| dist_x < *d) { + let allowed_dist = MAX_LINE_PROXIMITY.max(seg.width * 1.5); + if dist_x <= allowed_dist && closest_on_line.as_ref().map_or(true, |(_, d)| dist_x < *d) { closest_on_line = Some((i, dist_x)); } } @@ -513,13 +509,14 @@ impl ImageView { return Some(idx); } - // 3. Closest segment overall + // 3. Closest segment overall within strict proximity threshold (within 5% distance) + const MAX_OVERALL_DIST_SQ: f32 = 0.05 * 0.05; let mut closest: Option<(usize, f32)> = None; for (i, seg) in segments.iter().enumerate() { let center_x = seg.x + seg.width * 0.5; let center_y = seg.y + seg.height * 0.5; let dist_sq = (norm_x - center_x).powi(2) + (norm_y - center_y).powi(2); - if closest.as_ref().map_or(true, |(_, d)| dist_sq < *d) { + if dist_sq <= MAX_OVERALL_DIST_SQ && closest.as_ref().map_or(true, |(_, d)| dist_sq < *d) { closest = Some((i, dist_sq)); } } @@ -670,8 +667,8 @@ impl ImageView { } fn update_pdf_drag_selection(&mut self, cursor_pos: Point, cx: &mut Context) { - let pdf_info = self.image_item.read(cx).pdf_info.clone(); - let Some(ref pdf_info) = pdf_info else { return; }; + let item = self.image_item.read(cx); + let Some(ref pdf_info) = item.pdf_info else { return; }; if let Some((page_idx, seg_idx)) = self.find_page_and_segment_at(cursor_pos, pdf_info) { if let Some(ref mut sel) = self.pdf_selection { if sel.end_page != page_idx || sel.end_seg != seg_idx { @@ -809,8 +806,8 @@ impl ImageView { cx: &mut Context, ) { let bounds = self.page_bounds.borrow().get(&page_index).copied(); - let pdf_info = self.image_item.read(cx).pdf_info.clone(); - let Some(pdf_info) = pdf_info else { return; }; + let item = self.image_item.read(cx); + let Some(ref pdf_info) = item.pdf_info else { return; }; let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) else { return; }; if click_count >= 3 { @@ -891,8 +888,8 @@ impl ImageView { let norm_x = ((p_x - b_ox) / b_w).clamp(0.0, 1.0); let norm_y = ((p_y - b_oy) / b_h).clamp(0.0, 1.0); - let pdf_info = self.image_item.read(cx).pdf_info.clone(); - if let Some(pdf_info) = pdf_info { + let item = self.image_item.read(cx); + if let Some(ref pdf_info) = item.pdf_info { if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { let need_select = self.pdf_selection.as_ref().map_or(true, |s| { @@ -1115,7 +1112,17 @@ impl Element for ImageContentElement { let image_view = self.image_view.read(cx); let image = image_view.image_item.read(cx).image.clone(); - let zoom_level = image_view.zoom_level; + let first_layout = image_view.container_bounds.is_none(); + + let initial_zoom_level = first_layout + .then(|| { + image_view + .image_size + .map(|image_size| ImageView::compute_fit_to_view_zoom(bounds, image_size)) + }) + .flatten(); + + let zoom_level = initial_zoom_level.unwrap_or(image_view.zoom_level); let pan_offset = image_view.pan_offset; let border_color = cx.theme().colors().border; @@ -1145,6 +1152,9 @@ impl Element for ImageContentElement { let render_image = image.clone().use_render_image(window, cx); this.update_displayed_image(&image, render_image, window, cx); this.container_bounds = Some(bounds); + if let Some(initial_zoom_level) = initial_zoom_level { + this.zoom_level = initial_zoom_level; + } }); let mut image_content = div() @@ -1342,7 +1352,7 @@ impl Item for ImageView { pending_image: None, displayed_image: None, pdf_scroll_handle: gpui::ScrollHandle::new(), - show_text_panel: self.show_text_panel, + show_text_panel: false, text_buffer: None, text_editor: None, pdf_selection: self.pdf_selection.clone(), @@ -2512,6 +2522,31 @@ mod tests { assert!(!ImageView::is_safe_pdf_url("powershell:something")); assert!(!ImageView::is_safe_pdf_url("ms-msdt:/id DevDiagnostic")); } + + #[test] + fn test_find_segment_at_whitespace_rejection() { + let segments = vec![ + kkpdf_zed::PdfTextSegment { + text: "Hello".to_string(), + x: 0.2, + y: 0.2, + width: 0.1, + height: 0.05, + }, + ]; + + // Direct hit inside bounding box + assert_eq!(ImageView::find_segment_at(&segments, 0.25, 0.22), Some(0)); + + // Close horizontal proximity on same line + assert_eq!(ImageView::find_segment_at(&segments, 0.31, 0.22), Some(0)); + + // Far away horizontal position on same line (> allowed distance) + assert_eq!(ImageView::find_segment_at(&segments, 0.7, 0.22), None); + + // Far away arbitrary whitespace (e.g. bottom margin) + assert_eq!(ImageView::find_segment_at(&segments, 0.8, 0.8), None); + } } mod persistence { diff --git a/crates/kkpdf_zed/src/pdfium.rs b/crates/kkpdf_zed/src/pdfium.rs index 3ac0690f6c..e5d172a938 100644 --- a/crates/kkpdf_zed/src/pdfium.rs +++ b/crates/kkpdf_zed/src/pdfium.rs @@ -33,6 +33,30 @@ static GLOBAL_PDFIUM: OnceLock>> = OnceLock::new(); /// 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 { @@ -176,14 +200,12 @@ impl PdfiumEngine { .get(page_index as u16) .context("Requested page index out of bounds")?; - let target_width = - (page.width().value * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; - let target_height = - (page.height().value * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + 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) @@ -236,14 +258,12 @@ impl PdfiumEngine { let mut rendered_pages = Vec::with_capacity(total_pages); for (idx, page) in doc.pages().iter().enumerate() { - let target_width = - (page.width().value * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; - let target_height = - (page.height().value * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + 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) @@ -874,12 +894,12 @@ impl PdfiumEngine { let page_w = page.width().value; let page_h = page.height().value; - let target_width = (page_w * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; - let target_height = (page_h * (options.target_dpi / 72.0) * options.zoom_factor) - .round() - .clamp(1.0, MAX_PAGE_DIMENSION) as i32; + 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) @@ -1030,5 +1050,16 @@ mod tests { 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); } } From 93ed1b74c1ecce218d897ecd62c3eada3c029d96 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 20:45:47 +0530 Subject: [PATCH 17/19] feat(image_viewer): implement on demand LRU page rendering and clean dead code --- Cargo.lock | 1 + crates/image_viewer/Cargo.toml | 1 + crates/image_viewer/src/image_viewer.rs | 166 +++++++++++++++++++++++- crates/kkpdf_zed/src/cache.rs | 55 +++++++- crates/kkpdf_zed/src/lib.rs | 2 +- crates/project/src/image_store.rs | 84 +++--------- 6 files changed, 233 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4a15a9ca3..7a5917c7d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8759,6 +8759,7 @@ dependencies = [ "file_icons", "fs", "gpui", + "image", "kkpdf-zed", "language", "log", diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 44d1be0255..4175db8f68 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -21,6 +21,7 @@ db.workspace = true editor.workspace = true file_icons.workspace = true gpui.workspace = true +image.workspace = true kkpdf-zed.workspace = true language.workspace = true log.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 124ed5d643..3aeb80ce4a 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -23,6 +23,11 @@ use project::{ ImageItem, Project, ProjectPath, git_store::GitStoreEvent, image_store::{ImageItemEvent, PdfPageEntry, PdfPageInfo}, }; +use kkpdf_zed::{ + cache::{CacheKey, CacheablePage, PageLruCache, DEFAULT_MEMORY_BUDGET_BYTES}, + rasterizer::RasterizerOptions, + PdfiumEngine, +}; use settings::Settings; use theme_settings::ThemeSettings; use ui::{ @@ -104,6 +109,18 @@ impl PdfSelection { } } +#[derive(Clone)] +pub struct CachedPageImage { + pub image: Arc, + pub byte_size: usize, +} + +impl CacheablePage for CachedPageImage { + fn byte_size(&self) -> usize { + self.byte_size + } +} + pub struct ImageView { image_item: Entity, project: Entity, @@ -125,6 +142,7 @@ pub struct ImageView { pdf_autoscroll_task: Option>, context_menu: Option<(Entity, Point, Subscription)>, page_bounds: Rc>>>, + pdf_page_cache: PageLruCache, } struct DisplayedImage { @@ -253,6 +271,7 @@ impl ImageView { pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), + pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), } } @@ -272,11 +291,13 @@ impl ImageView { .image_metadata .map(|m| (m.width, m.height)); self.page_bounds.borrow_mut().clear(); + self.pdf_page_cache.clear(); cx.emit(ImageViewEvent::TitleChanged); cx.notify(); } ImageItemEvent::ReloadNeeded => { self.page_bounds.borrow_mut().clear(); + self.pdf_page_cache.clear(); cx.notify(); } } @@ -1048,6 +1069,46 @@ impl ImageView { let zoom_factor = 1.0 + event.delta; self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); } + + fn get_or_render_page_image( + &mut self, + pdf_bytes: &[u8], + page_index: usize, + ) -> Option> { + let key = CacheKey::new(page_index, self.zoom_level, false); + if let Some(cached) = self.pdf_page_cache.get(&key) { + return Some(cached.image); + } + + let engine = PdfiumEngine::new(); + let options = RasterizerOptions { + target_dpi: 144.0, + zoom_factor: self.zoom_level, + dark_mode: false, + saturation_threshold: 0.18, + }; + + if let Ok(rendered) = engine.render_page_from_bytes(pdf_bytes, page_index, options) { + let byte_size = rendered.byte_size(); + if let Some(img_buf) = image::ImageBuffer::, _>::from_raw( + rendered.width, + rendered.height, + rendered.rgba_buffer.as_ref().clone(), + ) { + let render_image = Arc::new(gpui::RenderImage::new(vec![image::Frame::new(img_buf)])); + self.pdf_page_cache.insert( + key, + CachedPageImage { + image: render_image.clone(), + byte_size, + }, + ); + return Some(render_image); + } + } + + None + } } struct ImageContentElement { @@ -1361,6 +1422,7 @@ impl Item for ImageView { pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), + pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), }))) } @@ -1470,6 +1532,32 @@ impl Render for ImageView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let pdf_info = self.image_item.read(cx).pdf_info.clone(); + let mut page_images: HashMap> = HashMap::default(); + if let Some(ref info) = pdf_info { + if !info.pages.is_empty() { + for page in &info.pages { + if let Some(img) = &page.image { + page_images.insert(page.page_index, img.clone()); + } + } + if !info.pdf_bytes.is_empty() { + let total_pages = info.total_pages; + let top_item = self.pdf_scroll_handle.top_item(); + let bottom_item = self.pdf_scroll_handle.bottom_item(); + let visible_start = top_item.saturating_sub(1); + let visible_end = (bottom_item.max(top_item + 1) + 1).min(total_pages.saturating_sub(1)); + + for page_idx in visible_start..=visible_end { + if !page_images.contains_key(&page_idx) { + if let Some(img) = self.get_or_render_page_image(&info.pdf_bytes, page_idx) { + page_images.insert(page_idx, img); + } + } + } + } + } + } + div() .track_focus(&self.focus_handle(cx)) .key_context("ImageViewer") @@ -1544,7 +1632,7 @@ impl Render for ImageView { .items_center() .py_6() .gap_8() - .children(pdf_info.pages.into_iter().map(|page| { + .children(pdf_info.pages.into_iter().map(|mut page| { let page_w = px(page.width as f32 * zoom_level); let page_h = px(page.height as f32 * zoom_level); let page_w_val = page.width as f32 * zoom_level; @@ -1553,6 +1641,7 @@ impl Render for ImageView { let segments_arc = Arc::new(page.text_segments); let links = page.links; let pdf_selection = self.pdf_selection.clone(); + let page_img = page_images.remove(&page_idx).or_else(|| page.image.take()); v_flex() .id(("pdf-page-container", page_idx)) @@ -1584,12 +1673,12 @@ impl Render for ImageView { this.handle_pdf_canvas_right_click(page_idx, event.position, window, cx); }), ) - .child( - img(page.image) + .children(page_img.map(|img_source| { + img(img_source) .id(("pdf-page-img", page_idx)) .size_full() - .absolute(), - ) + .absolute() + })) .child({ let page_bounds = self.page_bounds.clone(); let seg_range = pdf_selection.as_ref().and_then(|sel| { @@ -2428,7 +2517,7 @@ mod tests { page_index: 0, width: 100, height: 100, - image: dummy_img.clone(), + image: Some(dummy_img.clone()), page_text: "Hello world".into(), text_segments: vec![ PdfTextSegment { text: "Hello".into(), x: 0., y: 0., width: 10., height: 10. }, @@ -2440,7 +2529,7 @@ mod tests { page_index: 1, width: 100, height: 100, - image: dummy_img, + image: Some(dummy_img), page_text: "from page two".into(), text_segments: vec![ PdfTextSegment { text: "from".into(), x: 0., y: 0., width: 10., height: 10. }, @@ -2547,6 +2636,69 @@ mod tests { // Far away arbitrary whitespace (e.g. bottom margin) assert_eq!(ImageView::find_segment_at(&segments, 0.8, 0.8), None); } + + #[test] + fn test_pdf_page_cache_by_zoom_bucket() { + let mut cache: PageLruCache = PageLruCache::new(1024 * 1024); + let dummy_img = Arc::new(gpui::RenderImage::new(Vec::new())); + + let key_100 = CacheKey::new(0, 1.0, false); + let key_150 = CacheKey::new(0, 1.5, false); + + assert_ne!(key_100, key_150); + + cache.insert(key_100, CachedPageImage { + image: dummy_img.clone(), + byte_size: 400_000, + }); + cache.insert(key_150, CachedPageImage { + image: dummy_img.clone(), + byte_size: 400_000, + }); + + assert_eq!(cache.len(), 2); + assert_eq!(cache.current_memory_bytes(), 800_000); + assert!(cache.get(&key_100).is_some()); + assert!(cache.get(&key_150).is_some()); + + // Exceed budget (1MB = 1,048,576 bytes; 800_000 + 400_000 = 1,200_000 bytes) + let key_200 = CacheKey::new(0, 2.0, false); + cache.insert(key_200, CachedPageImage { + image: dummy_img, + byte_size: 400_000, + }); + + // key_100 should be evicted as the least recently used + assert_eq!(cache.len(), 2); + assert_eq!(cache.current_memory_bytes(), 800_000); + assert!(cache.get(&key_100).is_none()); + assert!(cache.get(&key_150).is_some()); + assert!(cache.get(&key_200).is_some()); + } + + #[test] + fn test_pdf_page_cache_clearing() { + let mut cache: PageLruCache = PageLruCache::new(1000); + let dummy_img = Arc::new(gpui::RenderImage::new(Vec::new())); + + cache.insert(CacheKey::new(0, 1.0, false), CachedPageImage { + image: dummy_img.clone(), + byte_size: 300, + }); + cache.insert(CacheKey::new(1, 1.0, false), CachedPageImage { + image: dummy_img, + byte_size: 300, + }); + + assert_eq!(cache.len(), 2); + assert_eq!(cache.current_memory_bytes(), 600); + + cache.clear(); + + assert_eq!(cache.len(), 0); + assert_eq!(cache.current_memory_bytes(), 0); + assert!(cache.is_empty()); + } } mod persistence { diff --git a/crates/kkpdf_zed/src/cache.rs b/crates/kkpdf_zed/src/cache.rs index 27d1508668..44a3ca7312 100644 --- a/crates/kkpdf_zed/src/cache.rs +++ b/crates/kkpdf_zed/src/cache.rs @@ -67,17 +67,28 @@ impl RenderedPage { } } +/// 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 { +pub struct PageLruCache { max_memory_bytes: usize, current_memory_bytes: usize, max_pages: Option, - entries: HashMap, + entries: HashMap, lru_order: VecDeque, } -impl PageLruCache { +impl PageLruCache { /// Creates a new cache with the specified memory budget in bytes. pub fn new(max_memory_bytes: usize) -> Self { Self { @@ -96,7 +107,7 @@ impl PageLruCache { } /// Retrieves a cached page, refreshing its LRU position. - pub fn get(&mut self, key: &CacheKey) -> Option { + pub fn get(&mut self, key: &CacheKey) -> Option { 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) { @@ -110,7 +121,7 @@ impl PageLruCache { } /// Inserts a newly rasterized page into the cache, evicting older pages if needed. - pub fn insert(&mut self, key: CacheKey, page: RenderedPage) { + pub fn insert(&mut self, key: CacheKey, page: T) { let page_size = page.byte_size(); // If replacing existing entry, remove old size first @@ -205,6 +216,12 @@ impl PageLruCache { 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 { @@ -300,4 +317,32 @@ mod tests { ); 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 = 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); + } } diff --git a/crates/kkpdf_zed/src/lib.rs b/crates/kkpdf_zed/src/lib.rs index 530696b8bb..a79647cc9f 100644 --- a/crates/kkpdf_zed/src/lib.rs +++ b/crates/kkpdf_zed/src/lib.rs @@ -19,7 +19,7 @@ pub mod ui; pub mod view; pub mod watcher; -pub use cache::{CacheKey, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES}; +pub use cache::{CacheKey, CacheablePage, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES}; pub use document::{PageDimensions, PdfDocument}; pub use pdfium::{ PdfDocumentDetails, PdfLinkAnnotation, PdfPageDetails, PdfTextSegment, PdfiumEngine, diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index b476a00295..a194848312 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -121,7 +121,7 @@ pub struct PdfPageEntry { pub page_index: usize, pub width: u32, pub height: u32, - pub image: Arc, + pub image: Option>, pub links: Vec, pub text_segments: Vec, pub page_text: String, @@ -1123,39 +1123,35 @@ pub fn create_gpui_images_from_pdf( pdf_bytes: &[u8], ) -> anyhow::Result<(Vec, Option)> { let engine = kkpdf_zed::PdfiumEngine::new(); - let options = kkpdf_zed::rasterizer::RasterizerOptions { - target_dpi: 144.0, - zoom_factor: 1.0, - dark_mode: false, - saturation_threshold: 0.18, - }; - - let doc_res = engine.render_and_extract_document_from_bytes(pdf_bytes, options)?; - let mut pages = Vec::with_capacity(doc_res.pages.len()); - - for page in doc_res.pages { - let img_buf = image::ImageBuffer::, _>::from_raw( - page.width, - page.height, - page.rgba_buffer, - ) - .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; - - let render_image = Arc::new(gpui::RenderImage::new(vec![image::Frame::new(img_buf)])); + 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, - height: page.height, - image: render_image, + 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, }); } - let full_text = if !doc_res.full_text.is_empty() { - Some(doc_res.full_text) + if pages.is_empty() && pdf_bytes.starts_with(b"%PDF-") { + pages.push(PdfPageEntry { + page_index: 0, + width: 612, + height: 792, + image: None, + links: Vec::new(), + text_segments: Vec::new(), + page_text: String::new(), + }); + } + + let full_text = if !details.full_text.is_empty() { + Some(details.full_text) } else { None }; @@ -1163,45 +1159,7 @@ pub fn create_gpui_images_from_pdf( Ok((pages, full_text)) } -pub fn create_gpui_image_from_pdf_page( - pdf_bytes: &[u8], - page_index: usize, -) -> anyhow::Result<(Arc, u32, u32)> { - let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); - let options = kkpdf_zed::rasterizer::RasterizerOptions { - target_dpi: 144.0, - zoom_factor: 1.0, - dark_mode: false, - saturation_threshold: 0.18, - }; - let page = engine.render_page_from_bytes(pdf_bytes, page_index, options)?; - - let mut png_bytes: Vec = Vec::new(); - let mut cursor = std::io::Cursor::new(&mut png_bytes); - let img_buf = image::ImageBuffer::, _>::from_raw( - page.width, - page.height, - page.rgba_buffer.as_ref().clone(), - ) - .ok_or_else(|| anyhow::anyhow!("Failed to convert RGBA to ImageBuffer"))?; - - img_buf.write_to(&mut cursor, image::ImageFormat::Png)?; - Ok(( - Arc::new(gpui::Image::from_bytes( - gpui::ImageFormat::Png, - png_bytes, - )), - page.width, - page.height, - )) -} - fn create_gpui_image(content: Vec) -> anyhow::Result> { - if content.starts_with(b"%PDF-") { - let (img, _, _) = create_gpui_image_from_pdf_page(&content, 0)?; - return Ok(img); - } - let format = image::guess_format(&content)?; Ok(Arc::new(gpui::Image::from_bytes( From 23e07e06f457bae23f22ac266a8b921ef6da031d Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 21:31:12 +0530 Subject: [PATCH 18/19] refactor(pdf): rename crate to pdf_viewer and enforce repo conventions --- Cargo.lock | 32 ++-- Cargo.toml | 4 +- README.md | 3 + crates/image_viewer/Cargo.toml | 2 +- crates/image_viewer/src/image_viewer.rs | 10 +- crates/kkpdf_zed/src/ui/mod.rs | 5 - crates/{kkpdf_zed => pdf_viewer}/Cargo.toml | 6 +- crates/{kkpdf_zed => pdf_viewer}/README.md | 63 ++++---- crates/{kkpdf_zed => pdf_viewer}/src/cache.rs | 0 .../{kkpdf_zed => pdf_viewer}/src/document.rs | 0 .../lib.rs => pdf_viewer/src/pdf_viewer.rs} | 12 +- .../{kkpdf_zed => pdf_viewer}/src/pdfium.rs | 153 ++++-------------- .../src/rasterizer.rs | 0 .../{kkpdf_zed => pdf_viewer}/src/settings.rs | 0 .../ui/toolbar.rs => pdf_viewer/src/ui.rs} | 2 +- crates/{kkpdf_zed => pdf_viewer}/src/view.rs | 0 .../{kkpdf_zed => pdf_viewer}/src/watcher.rs | 0 .../tests/integration_tests.rs | 2 +- crates/project/Cargo.toml | 2 +- crates/project/src/image_store.rs | 24 +-- crates/ui/src/components/scrollbar.rs | 8 +- 21 files changed, 114 insertions(+), 214 deletions(-) delete mode 100644 crates/kkpdf_zed/src/ui/mod.rs rename crates/{kkpdf_zed => pdf_viewer}/Cargo.toml (84%) rename crates/{kkpdf_zed => pdf_viewer}/README.md (52%) rename crates/{kkpdf_zed => pdf_viewer}/src/cache.rs (100%) rename crates/{kkpdf_zed => pdf_viewer}/src/document.rs (100%) rename crates/{kkpdf_zed/src/lib.rs => pdf_viewer/src/pdf_viewer.rs} (73%) rename crates/{kkpdf_zed => pdf_viewer}/src/pdfium.rs (87%) rename crates/{kkpdf_zed => pdf_viewer}/src/rasterizer.rs (100%) rename crates/{kkpdf_zed => pdf_viewer}/src/settings.rs (100%) rename crates/{kkpdf_zed/src/ui/toolbar.rs => pdf_viewer/src/ui.rs} (97%) rename crates/{kkpdf_zed => pdf_viewer}/src/view.rs (100%) rename crates/{kkpdf_zed => pdf_viewer}/src/watcher.rs (100%) rename crates/{kkpdf_zed => pdf_viewer}/tests/integration_tests.rs (97%) diff --git a/Cargo.lock b/Cargo.lock index 7a5917c7d7..2409e06f3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8760,10 +8760,10 @@ dependencies = [ "fs", "gpui", "image", - "kkpdf-zed", "language", "log", "menu", + "pdf_viewer", "project", "settings", "theme", @@ -9446,20 +9446,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "kkpdf-zed" -version = "0.1.0" -dependencies = [ - "anyhow", - "image", - "log", - "parking_lot", - "pdfium-render", - "serde", - "serde_json", - "tempfile", -] - [[package]] name = "kqueue" version = "1.2.0" @@ -12740,6 +12726,20 @@ 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" @@ -13959,7 +13959,6 @@ dependencies = [ "image", "indexmap 2.14.0", "itertools 0.14.0", - "kkpdf-zed", "language", "log", "lsp", @@ -13968,6 +13967,7 @@ dependencies = [ "parking_lot", "path", "paths", + "pdf_viewer", "percent-encoding", "postage", "prettier", diff --git a/Cargo.toml b/Cargo.toml index ac9143ebdd..bda77a5137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,7 +114,6 @@ members = [ "crates/journal", "crates/json_schema_store", "crates/keymap_editor", - "crates/kkpdf_zed", "crates/language", "crates/language_core", "crates/language_detection", @@ -159,6 +158,7 @@ members = [ "crates/panel", "crates/path", "crates/paths", + "crates/pdf_viewer", "crates/picker", "crates/picker_preview", "crates/platform_title_bar", @@ -377,7 +377,6 @@ http_client_tls = { path = "crates/http_client_tls" } http_proxy = { path = "crates/http_proxy" } icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } -kkpdf-zed = { path = "crates/kkpdf_zed" } input_latency_ui = { path = "crates/input_latency_ui" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } @@ -428,6 +427,7 @@ outline_panel = { path = "crates/outline_panel" } panel = { path = "crates/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" } diff --git a/README.md b/README.md index 9f641fb384..24840eec03 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +> [!IMPORTANT] +> Remove this line to confirm you've reviewed this PR before submitting. + # Zed [![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev) diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 4175db8f68..784d3de6d4 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -22,10 +22,10 @@ editor.workspace = true file_icons.workspace = true gpui.workspace = true image.workspace = true -kkpdf-zed.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 diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 3aeb80ce4a..9e06263d49 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -23,7 +23,7 @@ use project::{ ImageItem, Project, ProjectPath, git_store::GitStoreEvent, image_store::{ImageItemEvent, PdfPageEntry, PdfPageInfo}, }; -use kkpdf_zed::{ +use pdf_viewer::{ cache::{CacheKey, CacheablePage, PageLruCache, DEFAULT_MEMORY_BUDGET_BYTES}, rasterizer::RasterizerOptions, PdfiumEngine, @@ -492,7 +492,7 @@ impl ImageView { } fn find_segment_at( - segments: &[kkpdf_zed::PdfTextSegment], + segments: &[pdf_viewer::PdfTextSegment], norm_x: f32, norm_y: f32, ) -> Option { @@ -1774,7 +1774,7 @@ impl Render for ImageView { .custom_scrollbars( Scrollbars::new(ScrollAxes::Vertical) .id("pdf-scrollbar") - .style(ScrollbarStyle::Thick) + .style(ScrollbarStyle::Editor) .tracked_scroll_handle(&self.pdf_scroll_handle) .notify_content(), window, @@ -2508,7 +2508,7 @@ mod tests { #[test] fn test_pdf_collect_selection_text_multi_page() { - use kkpdf_zed::PdfTextSegment; + use pdf_viewer::PdfTextSegment; let dummy_img = Arc::new(gpui::RenderImage::new(Vec::new())); @@ -2615,7 +2615,7 @@ mod tests { #[test] fn test_find_segment_at_whitespace_rejection() { let segments = vec![ - kkpdf_zed::PdfTextSegment { + pdf_viewer::PdfTextSegment { text: "Hello".to_string(), x: 0.2, y: 0.2, diff --git a/crates/kkpdf_zed/src/ui/mod.rs b/crates/kkpdf_zed/src/ui/mod.rs deleted file mode 100644 index 61a506dacd..0000000000 --- a/crates/kkpdf_zed/src/ui/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! User interface components for PDF viewing. - -pub mod toolbar; - -pub use toolbar::{PdfToolbarAction, PdfToolbarState}; diff --git a/crates/kkpdf_zed/Cargo.toml b/crates/pdf_viewer/Cargo.toml similarity index 84% rename from crates/kkpdf_zed/Cargo.toml rename to crates/pdf_viewer/Cargo.toml index 1213944b47..fbc3a631d1 100644 --- a/crates/kkpdf_zed/Cargo.toml +++ b/crates/pdf_viewer/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "kkpdf-zed" +name = "pdf_viewer" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -8,6 +8,10 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[lib] +path = "src/pdf_viewer.rs" +doctest = false + [dependencies] anyhow.workspace = true image.workspace = true diff --git a/crates/kkpdf_zed/README.md b/crates/pdf_viewer/README.md similarity index 52% rename from crates/kkpdf_zed/README.md rename to crates/pdf_viewer/README.md index d451785840..a1531c5184 100644 --- a/crates/kkpdf_zed/README.md +++ b/crates/pdf_viewer/README.md @@ -1,15 +1,13 @@ -# `kkpdf-zed`: Native PDF Viewer for Zed Editor +# `pdf_viewer`: Native PDF Viewer for Zed -A native, high-performance PDF viewing engine and GPUI workspace item engineered for the [Zed Editor](https://zed.dev). +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. `kkpdf-zed` is designed as a native crate to be registered within the Zed workspace (`crates/kkpdf_viewer` or integrated upstream into `zed-industries/zed`). - -`extension.toml` in this repository is a **documentation stub** preserving metadata identity for a potential future extension surface or upstream contribution. +> 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`). --- @@ -17,26 +15,26 @@ A native, high-performance PDF viewing engine and GPUI workspace item engineered ``` ┌──────────────────────────────────────────────────────────────────────────────────┐ -│ KKPDF-ZED CORE PIPELINE │ +│ PDF_VIEWER CORE PIPELINE │ └──────────────────────────────────────────────────────────────────────────────────┘ │ [PDF Document on Disk / Memory Buffer] │ ▼ - [Thread-Safe Document Handle (`document.rs`)] + [Thread Safe Document Handle (`document.rs`)] │ ▼ [Async Background Worker Pool (`gpui::Task` / Threads)] │ - (Zero UI-Thread Blocking Rasterization) + (Zero UI Thread Blocking Rasterization) │ ▼ - [Memory-Budgeted LRU Cache (`cache.rs`)] + [Memory Budgeted LRU Cache (`cache.rs`)] │ - (Visible Viewport + 1-Page Margin) + (Visible Viewport + 1 Page Margin) │ ▼ - [Luminosity Tone-Mapping Engine (`ui/page.rs`)] + [Luminosity Tone Mapping Engine (`ui.rs`)] │ (Inverts White/Black; Preserves Images) │ @@ -44,28 +42,26 @@ A native, high-performance PDF viewing engine and GPUI workspace item engineered [Native GPUI Canvas / Bitmap Painter (`view.rs`)] ``` -1. **Zero UI-Thread Blocking (120 FPS Target)**: 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), `kkpdf-zed` 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. +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 (v1) +## 2. Scope Boundaries -- **Bitmap-Rendered Continuous Scroll**: Version 1 is engineered strictly as a high-fidelity, high-frame-rate read-only document viewer. -- **Excluded from v1**: - - Text selection and copying. - - In-document text search (`Ctrl+F`). +- **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. Text extraction and selection layer synchronization will be introduced in subsequent milestones. +- *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 in v1**: Because Pdfium runs in-process without sandboxing (e.g., without separate process IPC or WebAssembly memory isolation), malformed or malicious PDFs could theoretically trigger memory vulnerabilities in the underlying C++ library. +- **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. --- @@ -73,20 +69,21 @@ A native, high-performance PDF viewing engine and GPUI workspace item engineered ## 4. Repository Layout ``` -kkpdf-zed/ +crates/pdf_viewer/ ├── Cargo.toml # Native crate manifest -├── extension.toml # Documentation stub ├── README.md # Architecture & operational guide +├── tests/ +│ └── integration_tests.rs # End to end integration tests └── src/ - ├── lib.rs # Crate entrypoint & exports - ├── document.rs # Thread-safe PdfDocument handle + ├── 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 - ├── cache.rs # Memory-budgeted LRU cache with eviction - ├── watcher.rs # Live-reload file watcher & scroll state + ├── settings.rs # PDF viewer configuration options + ├── ui.rs # Toolbar state & action models ├── view.rs # GPUI Render & Workspace Item implementation - └── ui/ - ├── page.rs # Bitmap painting & luminosity recolor - └── toolbar.rs # Zoom, fit-width & page jump controls + └── watcher.rs # Live reload file watcher & scroll state ``` --- @@ -95,6 +92,4 @@ kkpdf-zed/ Run unit and integration tests across the caching and rasterization engines: -```bash -cargo test -``` +cargo test -p pdf_viewer diff --git a/crates/kkpdf_zed/src/cache.rs b/crates/pdf_viewer/src/cache.rs similarity index 100% rename from crates/kkpdf_zed/src/cache.rs rename to crates/pdf_viewer/src/cache.rs diff --git a/crates/kkpdf_zed/src/document.rs b/crates/pdf_viewer/src/document.rs similarity index 100% rename from crates/kkpdf_zed/src/document.rs rename to crates/pdf_viewer/src/document.rs diff --git a/crates/kkpdf_zed/src/lib.rs b/crates/pdf_viewer/src/pdf_viewer.rs similarity index 73% rename from crates/kkpdf_zed/src/lib.rs rename to crates/pdf_viewer/src/pdf_viewer.rs index a79647cc9f..6b76e4c532 100644 --- a/crates/kkpdf_zed/src/lib.rs +++ b/crates/pdf_viewer/src/pdf_viewer.rs @@ -1,12 +1,12 @@ -//! # `kkpdf-zed` +//! # `pdf_viewer` //! -//! High-performance native PDF viewing engine and GPUI workspace item for Zed Editor. +//! High performance native PDF viewing engine and GPUI workspace item for Zed Editor. //! //! Features: -//! - Sub-millisecond 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 +//! - 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 diff --git a/crates/kkpdf_zed/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs similarity index 87% rename from crates/kkpdf_zed/src/pdfium.rs rename to crates/pdf_viewer/src/pdfium.rs index e5d172a938..987539733b 100644 --- a/crates/kkpdf_zed/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -496,6 +496,25 @@ pub struct PdfDocumentRenderResult { pub pages: Vec, } +#[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, @@ -534,26 +553,8 @@ fn extract_page_text_and_links( let bottom = bounds.bottom().value.min(bounds.top().value); let top = bounds.bottom().value.max(bounds.top().value); - let norm_x = if page_w > 0.0 { - (left / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if page_h > 0.0 { - ((page_h - top) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_w = if page_w > 0.0 { - ((right - left) / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_h = if page_h > 0.0 { - ((top - bottom) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; + 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 { @@ -581,26 +582,9 @@ fn extract_page_text_and_links( if is_ws { if has_word_char && !word_text.is_empty() { - let norm_x = if page_w > 0.0 { - (min_left / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if page_h > 0.0 { - ((page_h - max_top) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_w = if page_w > 0.0 { - ((max_right - min_left) / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_h = if page_h > 0.0 { - ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; + 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 { @@ -636,26 +620,9 @@ fn extract_page_text_and_links( } if has_word_char && !word_text.is_empty() { - let norm_x = if page_w > 0.0 { - (min_left / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if page_h > 0.0 { - ((page_h - max_top) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_w = if page_w > 0.0 { - ((max_right - min_left) / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_h = if page_h > 0.0 { - ((max_top - min_bottom) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; + 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 { @@ -674,26 +641,8 @@ fn extract_page_text_and_links( let bottom = bounds.bottom().value.min(bounds.top().value); let top = bounds.bottom().value.max(bounds.top().value); - let norm_x = if page_w > 0.0 { - (left / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if page_h > 0.0 { - ((page_h - top) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_w = if page_w > 0.0 { - ((right - left) / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_h = if page_h > 0.0 { - ((top - bottom) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; + 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(), @@ -718,26 +667,8 @@ fn extract_page_text_and_links( let bottom = rect.bottom().value.min(rect.top().value); let top = rect.bottom().value.max(rect.top().value); - let norm_x = if page_w > 0.0 { - (left / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if page_h > 0.0 { - ((page_h - top) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_w = if page_w > 0.0 { - ((right - left) / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_h = if page_h > 0.0 { - ((top - bottom) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; + 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, @@ -766,26 +697,8 @@ fn extract_page_text_and_links( let bottom = rect.bottom().value.min(rect.top().value); let top = rect.bottom().value.max(rect.top().value); - let norm_x = if page_w > 0.0 { - (left / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if page_h > 0.0 { - ((page_h - top) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_w = if page_w > 0.0 { - ((right - left) / page_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_h = if page_h > 0.0 { - ((top - bottom) / page_h).clamp(0.0, 1.0) - } else { - 0.0 - }; + 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 diff --git a/crates/kkpdf_zed/src/rasterizer.rs b/crates/pdf_viewer/src/rasterizer.rs similarity index 100% rename from crates/kkpdf_zed/src/rasterizer.rs rename to crates/pdf_viewer/src/rasterizer.rs diff --git a/crates/kkpdf_zed/src/settings.rs b/crates/pdf_viewer/src/settings.rs similarity index 100% rename from crates/kkpdf_zed/src/settings.rs rename to crates/pdf_viewer/src/settings.rs diff --git a/crates/kkpdf_zed/src/ui/toolbar.rs b/crates/pdf_viewer/src/ui.rs similarity index 97% rename from crates/kkpdf_zed/src/ui/toolbar.rs rename to crates/pdf_viewer/src/ui.rs index d6f40af2c0..a09ea87a0d 100644 --- a/crates/kkpdf_zed/src/ui/toolbar.rs +++ b/crates/pdf_viewer/src/ui.rs @@ -1,4 +1,4 @@ -//! PDF viewer toolbar control models and layout definitions. +//! User interface components and toolbar state models for PDF viewing. //! //! Provides navigation, zoom presets, page jump controls, and dark mode toggles. diff --git a/crates/kkpdf_zed/src/view.rs b/crates/pdf_viewer/src/view.rs similarity index 100% rename from crates/kkpdf_zed/src/view.rs rename to crates/pdf_viewer/src/view.rs diff --git a/crates/kkpdf_zed/src/watcher.rs b/crates/pdf_viewer/src/watcher.rs similarity index 100% rename from crates/kkpdf_zed/src/watcher.rs rename to crates/pdf_viewer/src/watcher.rs diff --git a/crates/kkpdf_zed/tests/integration_tests.rs b/crates/pdf_viewer/tests/integration_tests.rs similarity index 97% rename from crates/kkpdf_zed/tests/integration_tests.rs rename to crates/pdf_viewer/tests/integration_tests.rs index c275bfca59..3ce13e6358 100644 --- a/crates/kkpdf_zed/tests/integration_tests.rs +++ b/crates/pdf_viewer/tests/integration_tests.rs @@ -1,4 +1,4 @@ -use kkpdf_zed::{PageLayoutMode, PdfToolbarState, PdfView, PdfViewerSettings}; +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"; diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 2a1e6af097..d82f995775 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -61,7 +61,6 @@ globset.workspace = true gpui.workspace = true http_client = { workspace = true, features = ["github-download"] } image.workspace = true -kkpdf-zed.workspace = true indexmap.workspace = true itertools.workspace = true language.workspace = true @@ -72,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 diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index a194848312..93f3cc30cd 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -122,8 +122,8 @@ pub struct PdfPageEntry { pub width: u32, pub height: u32, pub image: Option>, - pub links: Vec, - pub text_segments: Vec, + pub links: Vec, + pub text_segments: Vec, pub page_text: String, } @@ -178,9 +178,9 @@ pub fn project_image_source(project: WeakEntity, path: ProjectPath) -> impl ImageItem { pub fn compute_metadata_from_bytes(image_bytes: &[u8]) -> Result { if image_bytes.starts_with(b"%PDF-") { - let engine = kkpdf_zed::pdfium::PdfiumEngine::new(); + 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(kkpdf_zed::PageDimensions::new(612.0, 792.0)); + 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, @@ -278,7 +278,7 @@ impl ImageItem { if let Some(ref text) = info.full_text { Some(text.clone()) } else { - kkpdf_zed::PdfiumEngine::new().extract_text_from_bytes(&info.pdf_bytes, None).ok() + pdf_viewer::PdfiumEngine::new().extract_text_from_bytes(&info.pdf_bytes, None).ok() } } @@ -1122,7 +1122,7 @@ impl LocalImageStore { pub fn create_gpui_images_from_pdf( pdf_bytes: &[u8], ) -> anyhow::Result<(Vec, Option)> { - let engine = kkpdf_zed::PdfiumEngine::new(); + let engine = pdf_viewer::PdfiumEngine::new(); let details = engine.extract_document_details(pdf_bytes)?; let mut pages = Vec::with_capacity(details.pages.len()); @@ -1138,16 +1138,8 @@ pub fn create_gpui_images_from_pdf( }); } - if pages.is_empty() && pdf_bytes.starts_with(b"%PDF-") { - pages.push(PdfPageEntry { - page_index: 0, - width: 612, - height: 792, - image: None, - links: Vec::new(), - text_segments: Vec::new(), - page_text: String::new(), - }); + 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() { diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 7adc068d86..e7177f4eca 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -351,7 +351,6 @@ pub enum ScrollbarStyle { #[default] Regular, Editor, - Thick, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -375,7 +374,6 @@ impl ScrollbarStyle { match self { ScrollbarStyle::Regular => px(6.), ScrollbarStyle::Editor => px(15.), - ScrollbarStyle::Thick => px(14.), } } } @@ -1242,7 +1240,7 @@ impl Element for ScrollbarElement { bounds.size.apply_along(axis.invert(), |_| { width + match state.style { - ScrollbarStyle::Regular | ScrollbarStyle::Thick => 2 * SCROLLBAR_PADDING, + ScrollbarStyle::Regular => 2 * SCROLLBAR_PADDING, ScrollbarStyle::Editor => Pixels::ZERO, } }), @@ -1254,7 +1252,7 @@ impl Element for ScrollbarElement { // Rounded style needs a bit of padding, whereas for editor scrollbars, // we want the full length of the track let thumb_container_bounds = match state.style { - ScrollbarStyle::Regular | ScrollbarStyle::Thick => { + ScrollbarStyle::Regular => { scroll_track_bounds.dilate(-SCROLLBAR_PADDING) } ScrollbarStyle::Editor if has_border => scroll_track_bounds @@ -1490,7 +1488,7 @@ impl Element for ScrollbarElement { window.paint_quad(quad( *thumb_bounds, match style { - ScrollbarStyle::Regular | ScrollbarStyle::Thick => Corners::all(Pixels::MAX) + ScrollbarStyle::Regular => Corners::all(Pixels::MAX) .clamp_radii_for_quad_size(thumb_bounds.size), ScrollbarStyle::Editor => Corners::default(), }, From a912cb8832372716ff50f817301d1be288cf58e6 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 21:37:01 +0530 Subject: [PATCH 19/19] style(image_viewer): address clippy entry and redundant clone lints --- crates/image_viewer/src/image_viewer.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 9e06263d49..e703b1e9f3 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1548,9 +1548,9 @@ impl Render for ImageView { let visible_end = (bottom_item.max(top_item + 1) + 1).min(total_pages.saturating_sub(1)); for page_idx in visible_start..=visible_end { - if !page_images.contains_key(&page_idx) { + if let std::collections::hash_map::Entry::Vacant(e) = page_images.entry(page_idx) { if let Some(img) = self.get_or_render_page_image(&info.pdf_bytes, page_idx) { - page_images.insert(page_idx, img); + e.insert(img); } } } @@ -1695,7 +1695,7 @@ impl Render for ImageView { None } }); - let segs = segments_arc.clone(); + let segs = segments_arc; canvas( move |bounds: Bounds, _window: &mut Window, _cx: &mut App| { page_bounds.borrow_mut().insert(page_idx, bounds); @@ -1734,7 +1734,7 @@ impl Render for ImageView { let link_w = px(link.width * page_w_val); let link_h = px(link.height * page_h_val); let link_url = link.url.clone(); - let link_url_tooltip = link.url.clone(); + let link_url_tooltip = link.url; div() .id(("pdf-link", page_idx * 100_000 + link_idx))