From b8b3ea32d1b71d7345128333b62669ad42da54f8 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 2 Sep 2026 08:27:58 +0530 Subject: [PATCH] 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)?;