From ebf342c60b4b6b13d7b375ba6676bc8435dcdbcd Mon Sep 17 00:00:00 2001 From: yenru0 Date: Thu, 24 Sep 2026 04:00:15 +0900 Subject: [PATCH] image_viewer: Add properties panel, page rotation, and presentation mode - Document properties render as a sidebar with everything pdfium reports: title, author, subject, keywords, creator, producer, dates, page count, and file size. - Rotating pages turns all of them a quarter turn. Rendered bitmaps carry the rotation, overlay geometry (links, selection, find hits) is mapped into display space, and hit testing maps clicks back into page space. - Presentation mode lays out a single fitted page with sidebars, find bar, and page labels hidden; Escape leaves it. - Fit to view now also works for PDFs by fitting the current page into the viewport, which previously only applied to images. --- crates/image_viewer/src/image_viewer.rs | 487 ++++++++++++++++++++++-- crates/pdf_viewer/src/pdfium.rs | 106 +++++- crates/pdf_viewer/src/rasterizer.rs | 170 +++++++++ crates/pdf_viewer/src/view.rs | 1 + 4 files changed, 702 insertions(+), 62 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 4c297b3a19..adcc1c6f4f 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -37,7 +37,7 @@ use ui::{ ContextMenu, Divider, ScrollAxes, ScrollbarStyle, Scrollbars, Tooltip, WithScrollbar, prelude::*, }; -use util::{ResultExt as _, paths::PathExt}; +use util::{ResultExt as _, paths::PathExt, size::format_file_size}; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, delete_unloaded_items, @@ -77,6 +77,14 @@ actions!( ToggleThumbnailPanel, /// Toggle the document outline panel (PDF). ToggleOutlinePanel, + /// Toggle presentation mode: one fitted page with no surrounding chrome (PDF). + TogglePresentation, + /// Toggle the document properties panel (PDF). + TogglePropertiesPanel, + /// Rotate the pages a quarter turn clockwise (PDF). + RotateClockwise, + /// Rotate the pages a quarter turn counterclockwise (PDF). + RotateCounterclockwise, /// Open the find-in-document bar (PDF). FindInDocument, /// Jump to the next find-in-document match (PDF). @@ -108,6 +116,7 @@ const THUMBNAIL_DPI: f32 = 20.0; // Renders letter pages about 170px wide. const THUMBNAIL_PANEL_WIDTH: f32 = 150.0; const OUTLINE_PANEL_WIDTH: f32 = 220.0; const OUTLINE_INDENT: f32 = 12.0; +const PROPERTIES_PANEL_WIDTH: f32 = 220.0; #[derive(Clone, Debug, PartialEq)] pub struct PdfSelection { @@ -200,7 +209,11 @@ pub struct ImageView { pdf_find: Option, show_thumbnail_panel: bool, show_outline_panel: bool, + show_properties_panel: bool, + presentation_mode: bool, + page_rotation: u16, pdf_outline: Option>, + pdf_metadata: Option, thumbnail_scroll_handle: gpui::ScrollHandle, pdf_thumbnail_cache: PageLruCache, pdf_page_cache: PageLruCache, @@ -335,7 +348,11 @@ impl ImageView { pdf_find: None, show_thumbnail_panel: false, show_outline_panel: false, + show_properties_panel: false, + presentation_mode: false, + page_rotation: 0, pdf_outline: None, + pdf_metadata: None, thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -361,6 +378,7 @@ impl ImageView { self.pdf_page_cache.clear(); self.pdf_thumbnail_cache.clear(); self.pdf_outline = None; + self.pdf_metadata = None; if self.pdf_find.is_some() { self.refresh_pdf_find_matches(cx); } @@ -372,6 +390,7 @@ impl ImageView { self.pdf_page_cache.clear(); self.pdf_thumbnail_cache.clear(); self.pdf_outline = None; + self.pdf_metadata = None; if self.pdf_find.is_some() { self.refresh_pdf_find_matches(cx); } @@ -395,6 +414,11 @@ impl ImageView { } fn fit_to_view(&mut self, _: &FitToView, _window: &mut Window, cx: &mut Context) { + if self.image_item.read(cx).is_pdf() { + self.fit_pdf_page_to_viewport(cx); + return; + } + if let Some((bounds, image_size)) = self.container_bounds.zip(self.image_size) { self.zoom_level = ImageView::compute_fit_to_view_zoom(bounds, image_size); self.pan_offset = Point::default(); @@ -427,7 +451,7 @@ impl ImageView { if total_pages == 0 { return; } - let cur = self.pdf_scroll_handle.top_item(); + let cur = self.visible_pdf_page(cx); let target = (cur + 1).min(total_pages - 1); self.image_item.update(cx, |item, cx| { item.set_page(target, cx); @@ -441,7 +465,7 @@ impl ImageView { if total_pages == 0 { return; } - let cur = self.pdf_scroll_handle.top_item(); + let cur = self.visible_pdf_page(cx); let target = cur.saturating_sub(1); self.image_item.update(cx, |item, cx| { item.set_page(target, cx); @@ -504,7 +528,7 @@ impl ImageView { fn select_all(&mut self, _: &SelectAll, _window: &mut Window, cx: &mut Context) { 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 cur_page = self.visible_pdf_page(cx); let target_page = self .pdf_selection .as_ref() @@ -704,6 +728,8 @@ impl ImageView { } else { 0.0 }; + let (norm_x, norm_y) = + Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { @@ -754,6 +780,8 @@ impl ImageView { } else { 0.0 }; + let (norm_x, norm_y) = + Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); if let Some(seg_idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { @@ -967,6 +995,7 @@ impl ImageView { 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 (norm_x, norm_y) = Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); if let Some(idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { self.pdf_mouse_down = true; @@ -1296,7 +1325,12 @@ impl ImageView { self.image_item.update(cx, |item, cx| { item.set_page(target, cx); }); - self.pdf_scroll_handle.scroll_to_top_of_item(target); + if self.presentation_mode { + // Only the current page is laid out, so the viewport is already at its top. + self.pdf_scroll_handle.set_offset(point(px(0.0), px(0.0))); + } else { + self.pdf_scroll_handle.scroll_to_top_of_item(target); + } cx.notify(); } @@ -1321,6 +1355,11 @@ impl ImageView { cx: &mut Context, ) { let modifiers = event.keystroke.modifiers; + if event.keystroke.key.as_str() == "escape" && self.presentation_mode { + cx.stop_propagation(); + self.toggle_presentation(&TogglePresentation, window, cx); + return; + } if event.keystroke.key.as_str() != "space" || modifiers.control || modifiers.alt @@ -1462,6 +1501,7 @@ impl ImageView { zoom_factor: 1.0, dark_mode: false, saturation_threshold: 0.18, + rotation_degrees: self.page_rotation, }; let rendered = PdfiumEngine::new() @@ -1701,6 +1741,193 @@ impl ImageView { elements } + /// Page whose content the viewport currently shows. Presentation mode lays out a + /// single page, so the scroll handle's item index no longer maps to a page. + fn visible_pdf_page(&self, cx: &App) -> usize { + if self.presentation_mode { + self.image_item.read(cx).current_page() + } else { + self.pdf_scroll_handle.top_item() + } + } + + fn toggle_presentation( + &mut self, + _: &TogglePresentation, + _window: &mut Window, + cx: &mut Context, + ) { + if !self.image_item.read(cx).is_pdf() { + return; + } + self.presentation_mode = !self.presentation_mode; + if self.presentation_mode { + self.fit_pdf_page_to_viewport(cx); + } + cx.notify(); + } + + /// Scales the current page so the whole page fits the PDF viewport. + fn fit_pdf_page_to_viewport(&mut self, cx: &mut Context) { + let viewport = self.pdf_scroll_handle.bounds(); + if viewport.size.width <= px(0.0) || viewport.size.height <= px(0.0) { + return; + } + + let current_page = self.visible_pdf_page(cx); + let page_size = self.image_item.read(cx).pdf_info.as_ref().and_then(|info| { + info.pages + .iter() + .find(|page| page.page_index == current_page) + .map(|page| (page.width as f32, page.height as f32)) + }); + let Some((page_width, page_height)) = page_size else { + return; + }; + if page_width <= 0.0 || page_height <= 0.0 { + return; + } + + let scale = (viewport.size.width / page_width).min(viewport.size.height / page_height); + self.pdf_scroll_handle.set_offset(point(px(0.0), px(0.0))); + self.set_zoom(f32::from(scale), None, cx); + } + + fn toggle_properties_panel( + &mut self, + _: &TogglePropertiesPanel, + _window: &mut Window, + cx: &mut Context, + ) { + self.show_properties_panel = !self.show_properties_panel; + if self.show_properties_panel { + if self.pdf_metadata.is_none() { + let metadata = self + .image_item + .read(cx) + .pdf_info + .as_ref() + .and_then(|info| { + PdfiumEngine::new() + .extract_metadata_from_bytes(&info.pdf_bytes) + .log_err() + }) + .unwrap_or_default(); + self.pdf_metadata = Some(metadata); + } + // The properties panel occupies the same sidebar as the extracted text. + self.show_text_panel = false; + } + cx.notify(); + } + + /// Renders the document properties sidebar: one row per available metadata field. + fn render_properties_panel( + metadata: Option<&pdf_viewer::PdfMetadata>, + file_size: usize, + cx: &mut Context, + ) -> AnyElement { + let mut rows: Vec<(&str, Option)> = Vec::new(); + if let Some(metadata) = metadata { + rows.push(("Title", metadata.title.clone())); + rows.push(("Author", metadata.author.clone())); + rows.push(("Subject", metadata.subject.clone())); + rows.push(("Keywords", metadata.keywords.clone())); + rows.push(("Creator", metadata.creator.clone())); + rows.push(("Producer", metadata.producer.clone())); + rows.push(("Created", metadata.creation_date.clone())); + rows.push(("Modified", metadata.modification_date.clone())); + rows.push(("Pages", Some(metadata.page_count.to_string()))); + } + rows.push(("File size", Some(format_file_size(file_size as u64, true)))); + + div() + .id("pdf-properties") + .w(px(PROPERTIES_PANEL_WIDTH)) + .h_full() + .p_2() + .overflow_y_scroll() + .bg(cx.theme().colors().panel_background) + .child( + v_flex() + .gap_2() + .children(rows.into_iter().map(|(label, value)| { + v_flex() + .gap_1() + .child( + Label::new(label) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(value.unwrap_or_else(|| "—".to_string())) + .size(LabelSize::Small), + ) + .into_any_element() + })), + ) + .into_any_element() + } + + /// Rotates every page a quarter turn, invalidating the rendered bitmaps since + /// rotation is baked into the rasterized output. + fn rotate_pdf_pages(&mut self, delta: u16, cx: &mut Context) { + if !self.image_item.read(cx).is_pdf() { + return; + } + self.page_rotation = + pdf_viewer::rasterizer::normalize_rotation_degrees((self.page_rotation + delta) % 360); + self.pdf_page_cache.clear(); + self.pdf_thumbnail_cache.clear(); + self.page_bounds.borrow_mut().clear(); + cx.notify(); + } + + fn rotate_clockwise( + &mut self, + _: &RotateClockwise, + _window: &mut Window, + cx: &mut Context, + ) { + self.rotate_pdf_pages(90, cx); + } + + fn rotate_counterclockwise( + &mut self, + _: &RotateCounterclockwise, + _window: &mut Window, + cx: &mut Context, + ) { + self.rotate_pdf_pages(270, cx); + } + + /// Maps a normalized page-space box into display space for the current rotation. + fn rotate_normalized_box( + x: f32, + y: f32, + width: f32, + height: f32, + rotation: u16, + ) -> (f32, f32, f32, f32) { + match pdf_viewer::rasterizer::normalize_rotation_degrees(rotation) { + 90 => (1.0 - (y + height), x, height, width), + 180 => (1.0 - (x + width), 1.0 - (y + height), width, height), + 270 => (y, 1.0 - (x + width), height, width), + _ => (x, y, width, height), + } + } + + /// Maps a normalized display-space point back into page space, so hit testing can + /// keep comparing against unrotated text boxes. + fn unrotate_normalized_point(x: f32, y: f32, rotation: u16) -> (f32, f32) { + match pdf_viewer::rasterizer::normalize_rotation_degrees(rotation) { + 90 => (y, 1.0 - x), + 180 => (1.0 - x, 1.0 - y), + 270 => (1.0 - y, x), + _ => (x, y), + } + } + fn reveal_in_file_manager( &mut self, _: &RevealInFileManager, @@ -1825,6 +2052,7 @@ impl ImageView { zoom_factor: self.zoom_level, dark_mode: false, saturation_threshold: 0.18, + rotation_degrees: self.page_rotation, }; if let Ok(rendered) = engine.render_page_from_bytes(pdf_bytes, page_index, options) { @@ -2165,7 +2393,11 @@ impl Item for ImageView { pdf_find: None, show_thumbnail_panel: false, show_outline_panel: false, + show_properties_panel: false, + presentation_mode: false, + page_rotation: 0, pdf_outline: None, + pdf_metadata: None, thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -2287,11 +2519,17 @@ impl Render for ImageView { } } if !info.pdf_bytes.is_empty() { - let visible_pages = Self::visible_page_range( - self.pdf_scroll_handle.top_item(), - self.pdf_scroll_handle.bottom_item(), - info.total_pages, - ); + let visible_pages = if self.presentation_mode { + // Presentation mode lays out a single page, so only its bitmap is needed. + let current_page = self.visible_pdf_page(cx); + Some(current_page..=current_page) + } else { + Self::visible_page_range( + self.pdf_scroll_handle.top_item(), + self.pdf_scroll_handle.bottom_item(), + info.total_pages, + ) + }; for page_idx in visible_pages.into_iter().flatten() { if let std::collections::hash_map::Entry::Vacant(e) = @@ -2325,6 +2563,10 @@ impl Render for ImageView { .on_action(cx.listener(Self::toggle_text_panel)) .on_action(cx.listener(Self::toggle_thumbnail_panel)) .on_action(cx.listener(Self::toggle_outline_panel)) + .on_action(cx.listener(Self::toggle_presentation)) + .on_action(cx.listener(Self::toggle_properties_panel)) + .on_action(cx.listener(Self::rotate_clockwise)) + .on_action(cx.listener(Self::rotate_counterclockwise)) .on_action(cx.listener(Self::select_all)) .on_action(cx.listener(Self::open_pdf_find)) .on_action(cx.listener(Self::find_next_match)) @@ -2364,6 +2606,9 @@ impl Render for ImageView { let border_color = cx.theme().colors().border; let zoom_level = self.zoom_level; let total_pages = pdf_info.total_pages; + let presentation_mode = self.presentation_mode; + let rotation = self.page_rotation; + let current_page = self.visible_pdf_page(cx); let main_pdf_view = div() .id("pdf-viewport-wrapper") @@ -2391,11 +2636,22 @@ impl Render for ImageView { .items_center() .py_6() .gap_8() - .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; - let page_h_val = page.height as f32 * zoom_level; + .children( + pdf_info + .pages + .into_iter() + .filter(|page| { + !presentation_mode || page.page_index == current_page + }) + .map(|mut page| { + // Quarter turns swap the displayed page dimensions. + let (page_w_val, page_h_val) = if matches!(rotation, 90 | 270) { + (page.height as f32 * zoom_level, page.width as f32 * zoom_level) + } else { + (page.width as f32 * zoom_level, page.height as f32 * zoom_level) + }; + let page_w = px(page_w_val); + let page_h = px(page_h_val); let page_idx = page.page_index; let segments_arc = Arc::new(page.text_segments); let links = page.links; @@ -2469,7 +2725,16 @@ impl Render for ImageView { let is_active = find.current == Some(index); hit.rects .iter() - .map(move |rect| (*rect, is_active)) + .map(move |rect| { + let (x, y, width, height) = + ImageView::rotate_normalized_box( + rect.x, rect.y, rect.width, rect.height, rotation, + ); + ( + PdfFindRect { x, y, width, height }, + is_active, + ) + }) }) .collect() }) @@ -2510,14 +2775,18 @@ impl Render for ImageView { let highlight_color = gpui::rgba(0x3b82f644); for seg_idx in first_idx..=last_idx { if let Some(seg) = segs.get(seg_idx) { + let (seg_x, seg_y, seg_w, seg_h) = + ImageView::rotate_normalized_box( + seg.x, seg.y, seg.width, seg.height, rotation, + ); let quad_bounds = Bounds { origin: point( - b_ox + px(seg.x * b_w), - b_oy + px(seg.y * b_h), + 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), + px(seg_w * b_w), + px(seg_h * b_h), ), }; window.paint_quad(gpui::fill(quad_bounds, highlight_color).corner_radii(px(2.0))); @@ -2530,10 +2799,14 @@ impl Render for ImageView { .size_full() }) .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_x, link_y, link_w_val, link_h_val) = + Self::rotate_normalized_box( + link.x, link.y, link.width, link.height, rotation, + ); + let link_left = px(link_x * page_w_val); + let link_top = px(link_y * page_h_val); + let link_w = px(link_w_val * page_w_val); + let link_h = px(link_h_val * page_h_val); let link_url = link.url.clone(); let link_dest_page = link.dest_page; let link_tooltip = match link_dest_page { @@ -2569,11 +2842,17 @@ impl Render for ImageView { ) })) ) - .child( - Label::new(format!("Page {} of {}", page_idx + 1, total_pages)) + .when(!presentation_mode, |this| { + this.child( + Label::new(format!( + "Page {} of {}", + page_idx + 1, + total_pages + )) .size(LabelSize::XSmall) - .color(Color::Muted) - ) + .color(Color::Muted), + ) + }) })) ) .custom_scrollbars( @@ -2585,7 +2864,7 @@ impl Render for ImageView { window, cx, ) - .children(self.pdf_find.as_ref().map(|find| { + .children(self.pdf_find.as_ref().filter(|_| !presentation_mode).map(|find| { let status = if find.matches.is_empty() { if find.editor.read(cx).text(cx).trim().is_empty() { String::new() @@ -2598,9 +2877,13 @@ impl Render for ImageView { Self::render_pdf_find_bar(find.editor.clone(), status, cx) })); - if self.show_thumbnail_panel || self.show_outline_panel || self.show_text_panel { + if !presentation_mode + && (self.show_thumbnail_panel + || self.show_outline_panel + || self.show_text_panel + || self.show_properties_panel) + { let sidebar = if self.show_thumbnail_panel { - let current_page = self.pdf_scroll_handle.top_item(); Some(self.render_thumbnail_panel( pdf_info.pdf_bytes.clone(), total_pages, @@ -2619,12 +2902,7 @@ impl Render for ImageView { .when_some(sidebar, |this, sidebar| { this.child(sidebar).child(Divider::vertical()) }) - .child( - div() - .flex_1() - .h_full() - .child(main_pdf_view) - ) + .child(div().flex_1().h_full().child(main_pdf_view)) .when(self.show_text_panel, |this| { this.child(Divider::vertical()).child( div() @@ -2634,13 +2912,28 @@ impl Render for ImageView { .bg(cx.theme().colors().panel_background) .child( if let Some(editor) = &self.text_editor { - div().size_full().child(editor.clone()).into_any_element() + div() + .size_full() + .child(editor.clone()) + .into_any_element() } else { - div().size_full().child(Label::new("Extracting text...")).into_any_element() - } + div() + .size_full() + .child(Label::new("Extracting text...")) + .into_any_element() + }, ), ) }) + .when(self.show_properties_panel, |this| { + this.child(Divider::vertical()).child( + Self::render_properties_panel( + self.pdf_metadata.as_ref(), + pdf_info.pdf_bytes.len(), + cx, + ), + ) + }) .into_any_element() } else { main_pdf_view.into_any_element() @@ -2826,8 +3119,7 @@ impl Render for ImageViewToolbarControls { let view = image_view.read(cx); let show_text = view.show_text_panel; let current_page = view - .pdf_scroll_handle - .top_item() + .visible_pdf_page(cx) .min(item.total_pages().saturating_sub(1)); (item.is_pdf(), current_page, item.total_pages(), show_text) }; @@ -2881,7 +3173,43 @@ impl Render for ImageViewToolbarControls { let image_view_find = image_view.downgrade(); let image_view_thumbnails = image_view.downgrade(); let image_view_outline = image_view.downgrade(); + let image_view_presentation = image_view.downgrade(); + let image_view_properties = image_view.downgrade(); + let image_view_rotate_ccw = image_view.downgrade(); + let image_view_rotate_cw = image_view.downgrade(); this.child( + IconButton::new("toggle-properties", IconName::Info) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Document Properties", &TogglePropertiesPanel, cx) + }) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_properties.upgrade() { + view.update(cx, |this, cx| { + this.toggle_properties_panel( + &TogglePropertiesPanel, + window, + cx, + ); + }); + } + }), + ) + .child( + IconButton::new("toggle-presentation", IconName::Screen) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Presentation Mode", &TogglePresentation, cx) + }) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_presentation.upgrade() { + view.update(cx, |this, cx| { + this.toggle_presentation(&TogglePresentation, window, cx); + }); + } + }), + ) + .child( IconButton::new("toggle-outline", IconName::ListTree) .icon_size(IconSize::Small) .tooltip(|_window, cx| { @@ -2895,6 +3223,42 @@ impl Render for ImageViewToolbarControls { } }), ) + .child( + IconButton::new("rotate-counterclockwise", IconName::RotateCcw) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action( + "Rotate Counterclockwise", + &RotateCounterclockwise, + cx, + ) + }) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_rotate_ccw.upgrade() { + view.update(cx, |this, cx| { + this.rotate_counterclockwise( + &RotateCounterclockwise, + window, + cx, + ); + }); + } + }), + ) + .child( + IconButton::new("rotate-clockwise", IconName::RotateCw) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Rotate Clockwise", &RotateClockwise, cx) + }) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_rotate_cw.upgrade() { + view.update(cx, |this, cx| { + this.rotate_clockwise(&RotateClockwise, window, cx); + }); + } + }), + ) .child( IconButton::new("toggle-thumbnails", IconName::FileMultiple) .icon_size(IconSize::Small) @@ -3493,6 +3857,45 @@ mod tests { } } + #[test] + fn test_pdf_rotation_transforms() { + // A box in the top-left quarter turns to the top-right of the display. + let (x, y, width, height) = ImageView::rotate_normalized_box(0.0, 0.0, 0.5, 0.25, 90); + assert!((x - 0.75).abs() < 1e-6, "x was {}", x); + assert!((y - 0.0).abs() < 1e-6, "y was {}", y); + assert!((width - 0.25).abs() < 1e-6, "width was {}", width); + assert!((height - 0.5).abs() < 1e-6, "height was {}", height); + + // Half turns mirror both axes without swapping the sides. + let (x, y, width, height) = ImageView::rotate_normalized_box(0.1, 0.2, 0.3, 0.4, 180); + assert!((x - 0.6).abs() < 1e-6, "x was {}", x); + assert!((y - 0.4).abs() < 1e-6, "y was {}", y); + assert!((width - 0.3).abs() < 1e-6, "width was {}", width); + assert!((height - 0.4).abs() < 1e-6, "height was {}", height); + // No rotation leaves the box alone. + assert_eq!( + ImageView::rotate_normalized_box(0.1, 0.2, 0.3, 0.4, 0), + (0.1, 0.2, 0.3, 0.4) + ); + + // Hit testing maps display points back onto page space for every rotation. + for rotation in [0_u16, 90, 180, 270] { + for (x, y) in [(0.0_f32, 0.0_f32), (0.25, 0.75), (1.0, 1.0)] { + let (rx, ry, ..) = ImageView::rotate_normalized_box(x, y, 0.0, 0.0, rotation); + let (bx, by) = ImageView::unrotate_normalized_point(rx, ry, rotation); + assert!( + (bx - x).abs() < 1e-6 && (by - y).abs() < 1e-6, + "rotation {} should round trip ({}, {}) but got ({}, {})", + rotation, + x, + y, + bx, + by + ); + } + } + } + #[test] fn test_pdf_visible_page_range() { assert_eq!(ImageView::visible_page_range(0, 0, 0), None); diff --git a/crates/pdf_viewer/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs index 927eafd6f7..63b7462caf 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -11,7 +11,10 @@ use std::sync::Arc; use crate::cache::RenderedPage; use crate::document::{PageDimensions, PdfDocument}; -use crate::rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions}; +use crate::rasterizer::{ + LuminosityToneMapper, PageRasterizer, RasterizerOptions, combine_rotation_degrees, + normalize_rotation_degrees, +}; /// Internal wrapper granting thread-safe synchronization to Pdfium handle. struct SerializedPdfium(Option); @@ -57,6 +60,50 @@ pub(crate) fn compute_target_dimensions( (target_w, target_h) } +/// Converts a pdfium page rotation into its clockwise degree value. +fn rotation_as_degrees(rotation: PdfPageRenderRotation) -> u16 { + match rotation { + PdfPageRenderRotation::None => 0, + PdfPageRenderRotation::Degrees90 => 90, + PdfPageRenderRotation::Degrees180 => 180, + PdfPageRenderRotation::Degrees270 => 270, + } +} + +/// Converts a clockwise degree value into the pdfium page rotation to apply. +fn rotation_from_degrees(degrees: u16) -> PdfPageRenderRotation { + match normalize_rotation_degrees(degrees) { + 90 => PdfPageRenderRotation::Degrees90, + 180 => PdfPageRenderRotation::Degrees180, + 270 => PdfPageRenderRotation::Degrees270, + _ => PdfPageRenderRotation::None, + } +} + +/// Applies the viewer's clockwise rotation on top of the page's own intrinsic `/Rotate` +/// entry, returning the rotation the page carried beforehand. +/// +/// Pdfium applies the page rotation both when reporting page dimensions and when +/// rasterizing, so setting the combined rotation before rendering rotates the pixels and +/// swaps the reported frame dimensions for 90/270 degree turns. Callers should pass the +/// returned value to [`restore_page_rotation`] once rendering is complete so a reused +/// document is left the way it was found. +fn apply_page_rotation(page: &mut PdfPage<'_>, request_degrees: u16) -> u16 { + let intrinsic_degrees = page.rotation().map(rotation_as_degrees).unwrap_or(0); + let effective_degrees = combine_rotation_degrees(intrinsic_degrees, request_degrees); + + if effective_degrees != intrinsic_degrees { + page.set_rotation(rotation_from_degrees(effective_degrees)); + } + + intrinsic_degrees +} + +/// Restores the intrinsic page rotation returned by [`apply_page_rotation`]. +fn restore_page_rotation(page: &mut PdfPage<'_>, intrinsic_degrees: u16) { + page.set_rotation(rotation_from_degrees(intrinsic_degrees)); +} + /// Thread-safe wrapper around a Pdfium instance. #[derive(Clone)] pub struct PdfiumEngine { @@ -197,11 +244,15 @@ impl PdfiumEngine { .load_pdf_from_byte_slice(bytes, None) .context("Failed to load PDF in rasterizer")?; - let page = doc + let mut page = doc .pages() .get(page_index as u16) .context("Requested page index out of bounds")?; + // Rotate the page itself before measuring it: pdfium reports and renders the + // rotated frame, so the target pixel size below is already swapped for 90/270. + let intrinsic_rotation = apply_page_rotation(&mut page, options.rotation_degrees); + let (target_width, target_height) = compute_target_dimensions( page.width().value, page.height().value, @@ -215,11 +266,15 @@ impl PdfiumEngine { .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 = { + 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(); + bitmap.as_image().to_rgba8().into_raw() + }; + + restore_page_rotation(&mut page, intrinsic_rotation); if options.dark_mode { LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold); @@ -259,7 +314,9 @@ impl PdfiumEngine { } let mut rendered_pages = Vec::with_capacity(total_pages); - for (idx, page) in doc.pages().iter().enumerate() { + for (idx, mut page) in doc.pages().iter().enumerate() { + let intrinsic_rotation = apply_page_rotation(&mut page, options.rotation_degrees); + let (target_width, target_height) = compute_target_dimensions( page.width().value, page.height().value, @@ -273,11 +330,15 @@ impl PdfiumEngine { .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 = { + 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(); + bitmap.as_image().to_rgba8().into_raw() + }; + + restore_page_rotation(&mut page, intrinsic_rotation); if options.dark_mode { LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold); @@ -1020,13 +1081,14 @@ impl PdfiumEngine { 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; + for (idx, mut page) in doc.pages().iter().enumerate() { + // Rotation only affects the rasterized frame; text and link extraction + // below runs after the original rotation has been restored. + let intrinsic_rotation = apply_page_rotation(&mut page, options.rotation_degrees); let (target_width, target_height) = compute_target_dimensions( - page_w, - page_h, + page.width().value, + page.height().value, options.target_dpi, options.zoom_factor, ); @@ -1037,11 +1099,15 @@ impl PdfiumEngine { .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 = { + 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(); + bitmap.as_image().to_rgba8().into_raw() + }; + + restore_page_rotation(&mut page, intrinsic_rotation); if options.dark_mode { LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold); diff --git a/crates/pdf_viewer/src/rasterizer.rs b/crates/pdf_viewer/src/rasterizer.rs index db3e21683a..b5d3b5810d 100644 --- a/crates/pdf_viewer/src/rasterizer.rs +++ b/crates/pdf_viewer/src/rasterizer.rs @@ -18,6 +18,14 @@ pub struct RasterizerOptions { pub dark_mode: bool, /// Saturation threshold (0.0 to 1.0) above which colors are preserved (default 0.18). pub saturation_threshold: f32, + /// Clockwise page rotation in degrees (0, 90, 180, or 270). Any other value is + /// treated as 0. + /// + /// The rotation is applied on top of any intrinsic `/Rotate` entry already carried + /// by the page, so a document page rotated 90 degrees that is also rotated 90 degrees + /// by the viewer renders as 180 degrees. Turning the page by 90 or 270 degrees swaps + /// the rendered width and height, and callers must size their canvas accordingly. + pub rotation_degrees: u16, } impl Default for RasterizerOptions { @@ -27,10 +35,68 @@ impl Default for RasterizerOptions { zoom_factor: 1.0, dark_mode: false, saturation_threshold: 0.18, + rotation_degrees: 0, } } } +/// Normalizes a clockwise rotation request to the supported set {0, 90, 180, 270}. +/// +/// Any other value, including full turns such as 360, is treated as an unrotated page. +pub fn normalize_rotation_degrees(degrees: u16) -> u16 { + match degrees { + 90 | 180 | 270 => degrees, + _ => 0, + } +} + +/// Adds a page's intrinsic clockwise rotation to a viewer rotation request. +/// +/// Both inputs are normalized to the supported set and summed, so the four cardinal +/// rotations accumulate as expected (90 + 90 = 180, 270 + 270 = 180, 180 + 270 = 90). +pub fn combine_rotation_degrees(intrinsic_degrees: u16, request_degrees: u16) -> u16 { + let intrinsic = normalize_rotation_degrees(intrinsic_degrees); + let request = normalize_rotation_degrees(request_degrees); + + (intrinsic + request) % 360 +} + +/// Rotates a tightly packed RGBA8 frame clockwise, returning the rotated buffer along +/// with its new pixel dimensions; 90 and 270 degree turns swap width and height. +fn rotate_rgba_buffer( + buffer: Vec, + width: u32, + height: u32, + degrees: u16, +) -> (Vec, u32, u32) { + let (w, h) = (width as usize, height as usize); + + match degrees { + 90 | 180 | 270 => {} + // Unrotated frames are returned untouched; no copy is needed. + _ => return (buffer, width, height), + } + + let (dst_w, dst_h) = if degrees == 180 { (w, h) } else { (h, w) }; + let mut rotated = vec![0u8; buffer.len()]; + + for y in 0..h { + for x in 0..w { + let (dst_x, dst_y) = match degrees { + 90 => (h - 1 - y, x), + 180 => (w - 1 - x, h - 1 - y), + _ => (y, w - 1 - x), + }; + + let src = (y * w + x) * 4; + let dst = (dst_y * dst_w + dst_x) * 4; + rotated[dst..dst + 4].copy_from_slice(&buffer[src..src + 4]); + } + } + + (rotated, dst_w as u32, dst_h as u32) +} + /// Luminosity-preserving color remapper for dark themes. /// /// Unlike naive RGB inversion (`255 - C`) which turns colored photos, figures, @@ -126,6 +192,15 @@ impl PageRasterizer { LuminosityToneMapper::apply(&mut buffer, options.saturation_threshold); } + // Mirror the native path: the synthetic page is drawn upright and the finished + // frame is then turned clockwise, swapping width and height for 90/270 degrees. + let (buffer, width, height) = rotate_rgba_buffer( + buffer, + width, + height, + normalize_rotation_degrees(options.rotation_degrees), + ); + Ok(RenderedPage::new( page_index, width, @@ -195,6 +270,7 @@ mod tests { zoom_factor: 1.0, dark_mode: false, saturation_threshold: 0.18, + rotation_degrees: 0, }; let page = PageRasterizer::render_mock_page(0, dim, opts).expect("Mock render failed"); @@ -202,4 +278,98 @@ mod tests { assert_eq!(page.height, 800); assert_eq!(page.rgba_buffer.len(), 600 * 800 * 4); } + + #[test] + fn test_normalize_rotation_degrees_rejects_unsupported_values() { + assert_eq!(normalize_rotation_degrees(0), 0); + assert_eq!(normalize_rotation_degrees(90), 90); + assert_eq!(normalize_rotation_degrees(180), 180); + assert_eq!(normalize_rotation_degrees(270), 270); + + // Anything outside the supported set falls back to an unrotated page. + for unsupported in [1, 45, 89, 91, 359, 360, 450, u16::MAX] { + assert_eq!( + normalize_rotation_degrees(unsupported), + 0, + "{unsupported} should be treated as 0" + ); + } + } + + #[test] + fn test_combine_rotation_degrees_accumulates_clockwise() { + // Intrinsic page rotation plus viewer rotation, wrapped modulo a full turn. + assert_eq!(combine_rotation_degrees(0, 90), 90); + assert_eq!(combine_rotation_degrees(90, 90), 180); + assert_eq!(combine_rotation_degrees(90, 270), 0); + assert_eq!(combine_rotation_degrees(180, 270), 90); + assert_eq!(combine_rotation_degrees(270, 270), 180); + assert_eq!(combine_rotation_degrees(270, 180), 90); + + // Unsupported requests contribute nothing. + assert_eq!(combine_rotation_degrees(90, 45), 90); + assert_eq!(combine_rotation_degrees(90, 360), 90); + } + + #[test] + fn test_mock_rasterizer_swaps_dimensions_when_rotated() { + let dim = PageDimensions::new(600.0, 800.0); + + for (rotation_degrees, expected) in [(90u16, (800u32, 600u32)), (270, (800, 600))] { + let opts = RasterizerOptions { + target_dpi: 72.0, + zoom_factor: 1.0, + dark_mode: false, + saturation_threshold: 0.18, + rotation_degrees, + }; + + let page = PageRasterizer::render_mock_page(0, dim, opts).expect("Mock render failed"); + assert_eq!( + (page.width, page.height), + expected, + "rotation {rotation_degrees} should swap width and height" + ); + assert_eq!(page.rgba_buffer.len(), (expected.0 * expected.1 * 4) as usize); + } + + // Half turns keep the original footprint. + let opts = RasterizerOptions { + target_dpi: 72.0, + zoom_factor: 1.0, + dark_mode: false, + saturation_threshold: 0.18, + rotation_degrees: 180, + }; + + let page = PageRasterizer::render_mock_page(0, dim, opts).expect("Mock render failed"); + assert_eq!((page.width, page.height), (600, 800)); + } + + #[test] + fn test_rotate_rgba_buffer_turns_pixels_clockwise() { + // 2x1 frame: red pixel on the left, blue pixel on the right. + let buffer = vec![255u8, 0, 0, 255, 0, 0, 255, 255]; + + let (rotated, width, height) = rotate_rgba_buffer(buffer.clone(), 2, 1, 90); + assert_eq!((width, height), (1, 2)); + // Clockwise: the left pixel ends up on top, the right pixel at the bottom. + assert_eq!(&rotated[0..4], &[255, 0, 0, 255]); + assert_eq!(&rotated[4..8], &[0, 0, 255, 255]); + + let (rotated, width, height) = rotate_rgba_buffer(buffer.clone(), 2, 1, 270); + assert_eq!((width, height), (1, 2)); + assert_eq!(&rotated[0..4], &[0, 0, 255, 255]); + assert_eq!(&rotated[4..8], &[255, 0, 0, 255]); + + let (rotated, width, height) = rotate_rgba_buffer(buffer.clone(), 2, 1, 180); + assert_eq!((width, height), (2, 1)); + assert_eq!(&rotated[0..4], &[0, 0, 255, 255]); + assert_eq!(&rotated[4..8], &[255, 0, 0, 255]); + + // No rotation returns the very same buffer, untouched. + let (rotated, width, height) = rotate_rgba_buffer(buffer.clone(), 2, 1, 0); + assert_eq!((width, height), (2, 1)); + assert_eq!(rotated, buffer); + } } diff --git a/crates/pdf_viewer/src/view.rs b/crates/pdf_viewer/src/view.rs index 81e8bcf7c5..8128f8890a 100644 --- a/crates/pdf_viewer/src/view.rs +++ b/crates/pdf_viewer/src/view.rs @@ -327,6 +327,7 @@ impl PdfView { zoom_factor: self.zoom_level, dark_mode: self.dark_mode, saturation_threshold: self.settings.saturation_threshold, + rotation_degrees: 0, }; let rendered = if let Some(ref raw) = self.raw_bytes {