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:
1 parent
d6d891bd5c
commit
ebf342c60b
4 files changed
+702
-62
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user