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.
This commit is contained in:
yenru0 committed 2026-09-24 04:00:15 +09:00
1 parent d6d891bd5c
commit ebf342c60b
4 files changed
+702 -62

No files matched your search

+445 -42
View File
@@ -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<PdfFindState>,
show_thumbnail_panel: bool,
show_outline_panel: bool,
show_properties_panel: bool,
presentation_mode: bool,
page_rotation: u16,
pdf_outline: Option<Vec<pdf_viewer::PdfOutlineItem>>,
pdf_metadata: Option<pdf_viewer::PdfMetadata>,
thumbnail_scroll_handle: gpui::ScrollHandle,
pdf_thumbnail_cache: PageLruCache<CachedPageImage>,
pdf_page_cache: PageLruCache<CachedPageImage>,
@@ -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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Self>) {
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>,
) {
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<Self>,
) -> AnyElement {
let mut rows: Vec<(&str, Option<String>)> = 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<Self>) {
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>,
) {
self.rotate_pdf_pages(90, cx);
}
fn rotate_counterclockwise(
&mut self,
_: &RotateCounterclockwise,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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);
+86 -20
View File
@@ -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<Pdfium>);
@@ -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);
+170
View File
@@ -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<u8>,
width: u32,
height: u32,
degrees: u16,
) -> (Vec<u8>, 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);
}
}
+1
View File
@@ -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 {