From d26821fdcd67533e457115722934cbc12ef02d50 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 04:32:03 -0400 Subject: [PATCH] =?UTF-8?q?M3:=20View=20=E2=96=B8=20Zoom=20=E2=96=B8=20Fit?= =?UTF-8?q?=20visible=20(=E2=8C=983):=20zoom=20so=20the=20current=20page's?= =?UTF-8?q?=20visible=20content=20spans=20the=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 1 + crates/ui-egui/src/canvas.rs | 14 +++++++ crates/ui-egui/src/chrome.rs | 5 +++ crates/ui-egui/src/commands.rs | 7 ++++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/zoom_snap.rs | 70 +++++++++++++++++++++++++++++++++ crates/ui-egui/tests/viewer.rs | 18 +++++++++ parity/acrobat-features.toml | 8 ++-- 8 files changed, 120 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6c4778e5..e60bea35 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -165,6 +165,7 @@ pub const COMMANDS: &[CommandSpec] = &[ ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"), + c("view.fit_visible", "Fit visible", VIEW, Some(Shortcut::cmd("3")), Document, "scan"), c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"), c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f74f5f87..ffc5cf2e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -532,6 +532,20 @@ impl DocView { self.zoom_anchor = Some((page, f.x.clamp(0.0, 1.0), f.y.clamp(0.0, 1.0), self.viewport_screen.center() - self.viewport_screen.min)); } + /// View ▸ Zoom ▸ Fit Visible: zoom so the visible content of `page` (display-normalised + /// [x0, y0, x1, y1], i.e. after view rotation) spans the window's width, its left edge at + /// the window's left and its top at the top. + pub fn fit_content(&mut self, page: usize, content: [f32; 4]) -> bool { + const PAD: f32 = 16.0; + let Some((_, pr)) = self.screen_rects.iter().find(|(p, _)| *p == page).copied() else { return false }; + let w = (content[2] - content[0]).max(0.01) * pr.width(); + let k = (self.viewport_screen.width() - 2.0 * PAD).max(50.0) / w.max(1.0); + self.zoom = (self.zoom * k).clamp(0.08, 64.0); + self.fit = Fit::None; + self.zoom_anchor = Some((page, content[0].clamp(0.0, 1.0), content[1].clamp(0.0, 1.0), vec2(PAD, MARGIN))); + true + } + /// Zoom keeping the centre of the view still. pub fn set_zoom(&mut self, zoom: f32) { let centre = self.viewport_screen.center(); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3d82b949..de59e8d9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -177,6 +177,11 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) { v.fit = Fit::Height; v.goto = Some((v.current, 0.0)); } + if widgets::menu_item(ui, "Fit visible", "⌘3").clicked() { + ui.close(); + app.execute("view.fit_visible"); + return; + } if widgets::menu_item(ui, "Zoom in", "⌘+").clicked() { v.zoom_step(true); } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 5d4abd1c..1bc40642 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -308,6 +308,13 @@ impl PrintCraftApp { } "sign.panel" => self.right = Some(RightPanel::Signatures), "optimize.advanced" => self.dialog = Some(Dialog::Optimize), + "view.fit_visible" => { + if let Some(i) = self.active + && let Err(e) = self.fit_visible(i) + { + self.notify(format!("Couldn't fit the visible content: {e}")); + } + } "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, "edit.snapshot" => { self.quick_tool = crate::QuickTool::Snapshot; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a2afeef6..64543fa3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -869,6 +869,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { ("⌘1", "Actual size"), ("⌘0", "Zoom to page level"), ("⌘2", "Fit to width"), + ("⌘3", "Fit visible"), ("⌘+ / ⌘−", "Zoom in / out (also pinch or ⌘-scroll)"), ("⇧⌘+ / ⇧⌘−", "Rotate view"), ("Home / End", "First / last page"), diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index 08097338..64ed3566 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -72,6 +72,42 @@ impl PrintCraftApp { } } + /// View ▸ Zoom ▸ Fit Visible on the current page: find where the page has ink (a quick + /// low-resolution render) and zoom so that spans the window's width. A blank page fits the + /// width. + pub fn fit_visible(&mut self, index: usize) -> Result<(), String> { + let view = &self.views[index]; + let page = view.current; + let doc = self.session.get(view.id).ok_or("no document")?; + let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() }; + let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config); + // About 800 px along the longer side is plenty to find the margins. + let size = doc.info.pages.get(page).map_or(792.0, |p| p.width.max(p.height)); + let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0 }); + if let Some(e) = out.error { + return Err(e); + } + let rot = view.rotation; + let view = &mut self.views[index]; + match ink_bounds(out.width as usize, out.height as usize, &out.rgba) { + Some([u0, v0, u1, v1]) => { + // View space → the rotated view on screen. + let rotate = |u: f32, v: f32| match rot { + 90 => (1.0 - v, u), + 180 => (1.0 - u, 1.0 - v), + 270 => (v, 1.0 - u), + _ => (u, v), + }; + let (a, b) = (rotate(u0, v0), rotate(u1, v1)); + if !view.fit_content(page, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]) { + view.fit = crate::canvas::Fit::Width; + } + } + None => view.fit = crate::canvas::Fit::Width, + } + Ok(()) + } + /// Render `view_rect` (page view points) of `page` at the current zoom and copy it to the /// clipboard (and keep it in `last_snapshot`). pub fn snapshot(&mut self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32), String> { @@ -104,3 +140,37 @@ impl PrintCraftApp { Ok((w, h)) } } + +/// The normalised bounds [u0, v0, u1, v1] of the pixels that aren't (near) white, if any. +pub(crate) fn ink_bounds(w: usize, h: usize, rgba: &[u8]) -> Option<[f32; 4]> { + if w == 0 || h == 0 || rgba.len() < w * h * 4 { + return None; + } + let ink = |p: &[u8]| p[3] > 16 && (p[0] < 240 || p[1] < 240 || p[2] < 240); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); + for (y, row) in rgba.chunks_exact(w * 4).take(h).enumerate() { + for (x, p) in row.chunks_exact(4).enumerate() { + if ink(p) { + x0 = x0.min(x); + x1 = x1.max(x + 1); + y0 = y0.min(y); + y1 = y1.max(y + 1); + } + } + } + (x1 > x0 && y1 > y0).then(|| [x0 as f32 / w as f32, y0 as f32 / h as f32, x1 as f32 / w as f32, y1 as f32 / h as f32]) +} + +#[cfg(test)] +mod tests { + #[test] + fn ink_bounds_finds_the_marked_area() { + let (w, h) = (10, 20); + let mut px = vec![255u8; w * h * 4]; + for (x, y) in [(2, 5), (7, 15)] { + px[(y * w + x) * 4] = 0; + } + assert_eq!(super::ink_bounds(w, h, &px), Some([0.2, 0.25, 0.8, 0.8])); + assert_eq!(super::ink_bounds(w, h, &vec![255u8; w * h * 4]), None); + } +} diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index ca52cbe8..45e834a5 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -261,3 +261,21 @@ fn marquee_zoom_and_snapshot() { let after = h.state().views[i].zoom; assert!(after > before * 2.0, "{before} → {after}"); } + +#[test] +fn fit_visible_zooms_to_the_content_width() { + let mut h = harness(); + let i = h.state().active.unwrap(); + let before = h.state().views[i].zoom; + assert!(h.state_mut().execute("view.fit_visible")); + for _ in 0..4 { + h.run_steps(2); + } + let v = &h.state().views[i]; + assert!(v.zoom > before * 1.2, "{before} → {}", v.zoom); + // The text starts 20 pt in from the page's left; that edge is now near the window's left. + let r = v.page_screen_rect(v.current).expect("on screen"); + let ink_left = r.left() + 20.0 / 200.0 * r.width(); + let vp = v.viewport_rect(); + assert!((ink_left - vp.left() - 16.0).abs() < 12.0, "ink at {ink_left}, viewport {vp:?}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 29592ced..81a53c66 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -971,11 +971,11 @@ title = "Fit visible content" area = "B" tier = "P1" milestone = "M3" -status = "planned" -commands = [] +status = "shipped" +commands = ["view.fit_visible"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/viewer.rs::fit_visible_zooms_to_the_content_width", "crates/ui-egui/src/zoom_snap.rs::ink_bounds_finds_the_marked_area"] +notes = "View ▸ Zoom ▸ Fit visible (⌘3): the current page's inked area (found by a quick low-resolution render) spans the window width; respects view rotation; drivable via the control channel's command" [[feature]] id = "view.marquee-zoom"