diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 043bb37b0..ebcca532f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1126,6 +1126,35 @@ impl DocView { Some((*page, at)) } + /// The user-space `[left, top]` at the window's top-left (where `/XYZ` puts its point), on the + /// page showing there: what a destination's null coordinate keeps (ISO 32000-2 §12.3.2.2). + /// Acrobat keeps the place this way across pages, so a CAD sheet's cross-reference opens the + /// next sheet where the reader was looking. A coordinate is `None` along a screen axis the + /// page fits, which keeps the layout's own placement (centred, or the page's top); the whole + /// place is `None` before the first frame has laid pages out. + fn place_at_corner(&self, info: &DocInfo) -> Option<[Option; 2]> { + let area = self.viewport_screen; + if !area.is_positive() { + return None; + } + let corner = area.min + vec2(SIDE, 0.0); + let (page, xf) = self + .screen_xforms + .iter() + .filter(|(_, xf)| xf.rect.is_positive()) + .min_by(|a, b| a.1.rect.distance_sq_to_pos(corner).total_cmp(&b.1.rect.distance_sq_to_pos(corner)).then(a.0.cmp(&b.0)))?; + let p = info.pages.get(*page)?; + let (x, y) = xf.screen_to_view(corner.clamp(xf.rect.min, xf.rect.max)); + // `max` drops a NaN size, so the bounds stay ordered and `clamp` can't panic. + let (x, y) = (x.clamp(0.0, p.width.max(0.0)), y.clamp(0.0, p.height.max(0.0))); + // The page counts with its side gutters, as the layout places it. + let wide = xf.rect.width() + 2.0 * SIDE > area.width(); + let tall = xf.rect.height() > area.height(); + // The view's x runs across the screen unless the view is turned a quarter. + let (keep_x, keep_y) = if xf.rot.is_multiple_of(180) { (wide, tall) } else { (tall, wide) }; + Some(p.xyz_for_view(keep_x.then_some(x), keep_y.then_some(y))) + } + /// A page's thumbnail texture, when rendered. pub(crate) fn thumb_id(&self, page: usize) -> Option { self.thumbs.get(&page).map(|t| t.tex.id()) @@ -1328,10 +1357,16 @@ impl DocView { /// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's /// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit /// page, width and height with `top` or `left` at the window's edge; `/FitR` zooms so the - /// rectangle fits, centred. The `B` (content box) forms are read as the page. A null - /// coordinate keeps that scroll position (null `top` on a new page: its top), and a point - /// past the end of the document scrolls as far as it can. + /// rectangle fits, centred. The `B` (content box) forms are read as the page. A null `/XYZ` + /// coordinate keeps the reader's place: the same user-space point stays at the window's + /// top-left on the new page, as Acrobat does. A point past the end of the document scrolls + /// as far as it can. pub fn go_to_dest(&mut self, page: usize, dest: DestView, info: &DocInfo) { + // Read before the jump: it is where the reader is now. + let kept = match dest { + DestView::Xyz { left, top, .. } if left.is_none() || top.is_none() => self.place_at_corner(info), + _ => None, + }; self.go_to_page(page); let page = self.current; self.goto_point = None; @@ -1348,7 +1383,8 @@ impl DocView { self.fit = Fit::None; self.zoom_anchor = None; } - let [fx, fy] = point(left, top); + let [kept_left, kept_top] = kept.unwrap_or([None, None]); + let [fx, fy] = point(left.or(kept_left), top.or(kept_top)); (fx, fy, corner) } DestView::Fit => { @@ -6853,6 +6889,47 @@ trailer << /Root 1 0 R >> assert_eq!(PageLayout::try_parse("facing"), None); } + #[test] + fn a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page() { + // CAD exports link each cross-reference as `/XYZ null null 0`. Acrobat keeps the + // point at the window's top-left (and the zoom) when following one to another sheet. + let info = pdfcraft_render::DocInfo { + pages: (0..3) + .map(|_| pdfcraft_render::PageInfo { width: 842.0, height: 595.0, label: String::new(), crop: [0.0, 0.0, 842.0, 595.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + // Page 1 at 400 %, scrolled so its point 200 pt across and 125 pt down is at the corner + // where /XYZ puts its point. + let zoomed = |rot: u16, size: Vec2| { + let mut v = view(3, PageLayout::Single, true); + v.rotation = rot; + v.viewport_screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 600.0)); + v.screen_xforms = vec![(0, PageXform { rect: Rect::from_min_size(pos2(SIDE - 800.0, -500.0), size), rot, pw: 842.0, ph: 595.0 })]; + v + }; + let near = |a: Option, b: f32| a.is_some_and(|a| (a - b).abs() < 1e-4); + let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + let (page, fx, fy, rel) = v.goto_point.expect("the place is kept"); + assert_eq!((v.current, page, rel), (1, 1, vec2(SIDE, 0.0))); + assert!(near(fx, 200.0 / 842.0) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}"); + // A coordinate the destination gives wins; only the null one is kept. + let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0)); + v.go_to_dest(2, DestView::Xyz { left: Some(421.0), top: None, zoom: Some(4.0) }, &info); + let (_, fx, fy, _) = v.goto_point.expect("the place is kept"); + assert!(near(fx, 0.5) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}"); + // With the view turned a quarter, the point under the corner is still the one kept. + let mut v = zoomed(90, vec2(4.0 * 595.0, 4.0 * 842.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + let (_, fx, fy, _) = v.goto_point.expect("the place is kept"); + assert!(near(fx, 800.0 / (4.0 * 595.0)) && near(fy, 500.0 / (4.0 * 842.0)), "{fx:?} {fy:?}"); + // A page that fits the window keeps the layout's placement: no point, as before. + let mut v = zoomed(0, vec2(800.0, 565.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + assert_eq!((v.current, v.goto, v.goto_point), (1, Some((1, 0.0)), None)); + } + #[test] fn destinations_set_the_zoom_mode_and_the_point_to_show() { let info = pdfcraft_render::DocInfo { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index dc1d5585d..c064bf426 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1137,10 +1137,15 @@ trailer << /Root 1 0 R >> %%EOF"; fn dests_harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + bookmarks_harness("dests.pdf", DESTS) +} + +/// `pdf` open with the Bookmarks panel showing, once the window's layout holds still. +fn bookmarks_harness(name: &'static str, pdf: &'static [u8]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("dests.pdf", None, DESTS.to_vec()).expect("opens"); + app.open_bytes(name, None, pdf.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "bookmarks").unwrap(); app @@ -1190,13 +1195,15 @@ fn bookmark_destinations_scroll_to_their_position() { let d = from_viewport_corner(&h, 1, 0.25, 0.5); assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "the destination point is at the window's top-left: {d:?}"); - // Malformed operands fall back to the top of the page at the current zoom. + // Unusable operands are read as null, which keeps the reader's place in user space: y 300 + // was at the window's top on page 2 (its crop box starts at y 100), so on page 3 (crop from + // 0) y 300 is there too, a quarter down, at the current zoom. h.get_by_label("Broken").click(); h.run_steps(6); let v = &h.state().views[0]; assert_eq!((v.current, v.zoom), (2, 3.0)); - let d = from_viewport_corner(&h, 2, 0.0, 0.0); - assert!(d.y > 0.0 && d.y < 40.0, "page 3 is scrolled to its top: {d:?}"); + let d = from_viewport_corner(&h, 2, 0.0, 0.25); + assert!(d.y.abs() < 2.0, "y 300 on page 3 is at the top of the window: {d:?}"); } #[test] @@ -1219,6 +1226,42 @@ fn link_destinations_scroll_to_their_position() { assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window: {d:?}"); } +/// Two 300×400 pages. A bookmark shows page 1 at 400% with (40, 360) at the window's top-left; +/// the link there goes to page 2 as CAD exports write their cross-references, `/XYZ null null 0`. +const KEEP_PLACE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [20 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +10 0 obj << /Type /Outlines /First 11 0 R /Last 11 0 R /Count 1 >> endobj +11 0 obj << /Title (Start) /Parent 10 0 R /Dest [3 0 R /XYZ 40 360 4] >> endobj +20 0 obj << /Type /Annot /Subtype /Link /Rect [60 320 140 350] /Border [0 0 0] /Dest [4 0 R /XYZ null null 0] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn a_link_without_a_position_keeps_the_place_on_the_next_page() { + let mut h = bookmarks_harness("keep-place.pdf", KEEP_PLACE); + h.get_by_label("Start").click(); + h.run_steps(6); + assert_eq!((h.state().views[0].current, h.state().views[0].zoom), (0, 4.0)); + let (fx, fy) = (40.0 / 300.0, 40.0 / 400.0); + let d = from_viewport_corner(&h, 0, fx, fy); + assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "(40, 360) is at the window's top-left: {d:?}"); + + let r = rect(&h, 0).expect("page 1"); + let at = egui::pos2(r.min.x + r.width() * (100.0 / 300.0), r.min.y + r.height() * (1.0 - 335.0 / 400.0)); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); // press + h.drop_at(at); // release + h.run_steps(8); + let v = &h.state().views[0]; + assert_eq!((v.current, v.zoom), (1, 4.0), "the link goes to page 2 at the same zoom"); + let d = from_viewport_corner(&h, 1, fx, fy); + assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "page 2 shows the same place, not its top: {d:?}"); +} + /// Two pages with a text field on each and a non-embedded Helvetica. const FORM: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] >> >> endobj diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f88cd718b..e32e6c9dd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1178,8 +1178,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["link_open"] -evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] -notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit." +evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] +notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge." [[feature]] id = "view.annotation-hover"