Merge PR #4303: M3: a link without a position keeps the reader's place (ISO 32000-2 §12.3.2.2)

This commit is contained in:
aiongg
2026-10-24 06:23:11 +00:00
3 changed files with 131 additions and 11 deletions
+81 -4
View File
@@ -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<f32>; 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<egui::TextureId> {
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<f32>, 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 {
+48 -5
View File
@@ -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
+2 -2
View File
@@ -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"