From fc91ab9250eb4b577aa96fec4edf0c2791bfbd53 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:30:21 +0800 Subject: [PATCH] M3: a document opens at its /OpenAction destination, position and named destinations included (ISO 32000-2 Table 149, 12.3.2.4) (#4376) - render: DocInfo::open_dest resolves /OpenAction (an explicit destination, a /GoTo action's /D, or a name or string through /Names /Dests or the catalog /Dests) with the existing bounded dest() resolver. - ui-egui: apply_initial_view goes there with DocView::go_to_dest, so the /XYZ point lands at the window's top-left and /FitH top at its top edge. With "Always use my default zoom and page layout", only the page. - organize, ui-egui: /ViewerPreferences /NonFullScreenPageMode (Table 147) is read for /PageMode /FullScreen documents and names the panel shown behind the full-screen prompt; such a document no longer gets the outline panel only because it has an outline. - organize: set_initial_view keeps an opening action whose page and magnification are unchanged, instead of dropping its position or deleting a named go-to. - automation: doc_info reports open_destination. Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC Co-authored-by: Claude Opus 5.5 --- crates/automation/src/lib.rs | 3 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 43 +++++ crates/organize/src/tests.rs | 40 +++++ crates/organize/src/view.rs | 59 ++++++- crates/render/src/inspect.rs | 132 ++++++++++++++++ crates/ui-egui/src/lib.rs | 32 +++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/open_action.rs | 191 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 10 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/tests/it/open_action.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index a032186c5..e94354021 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2635,6 +2635,9 @@ fn info(d: &Document) -> Value { "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, + // Where the document opens (/OpenAction, named destinations resolved): 1-based page and + // destination, null without one. + "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9441d7e0c..1bfcc7798 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -139,7 +139,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c2eb4b71c..cf124f169 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -213,6 +213,49 @@ fn doc_info_lists_page_transitions_and_display_times() { ); } +/// `doc_info` says where the document opens: the `/OpenAction` destination, a named one +/// resolved (ISO 32000-2 §12.3.2.4), as a 1-based page and a destination in view coordinates. +#[test] +fn doc_info_reports_the_open_destination() { + let dir = workdir("open-destination"); + let write = |name: &str, catalog: &str| { + let objects: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join(name), bytes).unwrap(); + }; + write("named.pdf", "/OpenAction << /S /GoTo /D /there >> /Dests << /there [4 0 R /XYZ 100 500 2] >>"); + write("script.pdf", "/OpenAction << /S /JavaScript /JS (1) >>"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "named.pdf" }))["doc"].as_u64().unwrap(); + let open = ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].clone(); + assert_eq!(open["page"], 2, "{open}"); + // y 500 is 292 points below the top of the 792-point page. + assert_eq!( + (open["destination"]["fit"].as_str(), open["destination"]["left"].as_f64(), open["destination"]["top"].as_f64()), + (Some("XYZ"), Some(100.0), Some(292.0)), + "{open}" + ); + assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0)); + let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null()); +} + /// Page transitions through the tools: set on chosen pages, written to the file as `/Trans` and /// `/Dur`, removed again, bad values refused, and one undo step. (Read through `pdfcraft_cos`, /// so this does not lean on `doc_info` listing them.) diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 02cee53d4..95e997284 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2392,6 +2392,46 @@ fn initial_view_round_trips_and_keeps_scripts() { assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err()); } +#[test] +fn an_unchanged_opening_destination_keeps_its_position_and_name() { + use crate::view::{Magnification, Navigation}; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + let xyz = Object::Array(vec![page2.clone(), Object::name("XYZ"), Object::Int(100), Object::Int(500), Object::Int(2)]); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"S".to_vec(), Object::name("GoTo")); + named.set(b"D".to_vec(), Object::name("there")); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page2, Object::name("Fit")])); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"NonFullScreenPageMode".to_vec(), Object::name("UseOutlines")); + for action in [xyz, Object::Dict(named)] { + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action.clone()); + c.set(b"Dests".to_vec(), Object::Dict(dests.clone())); + c.set(b"PageMode".to_vec(), Object::name("FullScreen")); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(prefs.clone())); + }) + .unwrap(); + // Another option changes: the opening action is written back as it was, and so is the + // full-screen panel, which the dialog doesn't edit. + let v = crate::initial_view(&doc); + assert_eq!(v.non_full_screen, Navigation::Bookmarks); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_eq!(catalog.get(b"OpenAction"), Some(&action)); + let doc = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() }); + // A changed magnification writes a destination of its own. + let mut doc = doc; + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitPage, ..v }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_ne!(catalog.get(b"OpenAction"), Some(&action)); + assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitPage); + } +} + // ── Split / Extract keep only the resources the part draws (#204) ────────────────────────────── /// A content stream with no text operators (an image draw). diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index 0f115d305..b51df1aa5 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -63,6 +63,10 @@ pub struct InitialView { pub language: Option, /// Right-edge binding (`/Direction /R2L`). pub right_to_left: bool, + /// The navigation panel on leaving full screen (`/ViewerPreferences /NonFullScreenPageMode`, + /// ISO 32000-2 Table 147), read only when `full_screen` is set. It is read, not written: + /// [`set_initial_view`] leaves the entry as it is. + pub non_full_screen: Navigation, } fn catalog(doc: &Document) -> Result<(pdfcraft_cos::ObjRef, Dict), OrganizeError> { @@ -104,6 +108,15 @@ pub fn initial_view(doc: &Document) -> InitialView { v.hide_toolbar = flag(b"HideToolbar"); v.hide_window_ui = flag(b"HideWindowUI"); v.right_to_left = name(&vp, b"Direction").as_deref() == Some(b"R2L"); + // Table 147: meaningful only with `/PageMode /FullScreen`, and only these four values. + if v.full_screen { + v.non_full_screen = match name(&vp, b"NonFullScreenPageMode").as_deref() { + Some(b"UseOutlines") => Navigation::Bookmarks, + Some(b"UseThumbs") => Navigation::Pages, + Some(b"UseOC") => Navigation::Layers, + _ => Navigation::PageOnly, + }; + } } // The opening destination: an explicit array, or a GoTo action's. let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o { @@ -149,8 +162,13 @@ pub fn displays_doc_title(doc: &Document) -> bool { } /// Write the initial view. An opening action that isn't a plain destination (a script, say) is -/// kept unless the page or magnification asks for a destination. +/// kept unless the page or magnification asks for a destination, and an opening action whose page +/// and magnification `v` leaves as [`initial_view`] reads them is kept as it is. pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> { + let same_destination = { + let now = initial_view(doc); + now.page == v.page && now.magnification == v.magnification + }; let (root, c) = catalog(doc)?; let pages = crate::walk(doc)?; if v.page >= pages.len() { @@ -238,6 +256,9 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } } match (&dest, open_action_is_dest) { + // The opening page and magnification are as read: the action stays as written, with + // its position or named destination (§12.3.2.4) that the view model doesn't hold. + _ if same_destination => {} (Some(d), _) => c.set(b"OpenAction".to_vec(), Object::Array(d.clone())), (None, true) => { c.remove(b"OpenAction"); @@ -294,4 +315,40 @@ mod tests { assert_eq!(flags(initial_view(&doc)), [false; 6]); } } + + /// A catalog with `/PageMode page_mode` and `/ViewerPreferences << /NonFullScreenPageMode mode >>`. + fn with_modes(page_mode: &str, mode: Object) -> Document { + let mut doc = Document::new_empty(); + let mode = doc.add(mode); + let mut preferences = Dict::new(); + preferences.set(b"NonFullScreenPageMode".to_vec(), Object::Ref(mode)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"PageMode".to_vec(), Object::name(page_mode)); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences)); + }) + .unwrap(); + doc + } + + #[test] + fn non_full_screen_page_mode_is_read_with_full_screen_only() { + for (mode, want) in [ + ("UseOutlines", Navigation::Bookmarks), + ("UseThumbs", Navigation::Pages), + ("UseOC", Navigation::Layers), + ("UseNone", Navigation::PageOnly), + // Not a value Table 147 lists. + ("UseAttachments", Navigation::PageOnly), + ("FullScreen", Navigation::PageOnly), + ] { + let v = initial_view(&with_modes("FullScreen", Object::name(mode))); + assert!(v.full_screen); + assert_eq!(v.non_full_screen, want, "{mode}"); + // Ignored unless the document opens in full screen. + let v = initial_view(&with_modes("UseThumbs", Object::name(mode))); + assert_eq!((v.navigation, v.non_full_screen), (Navigation::Pages, Navigation::PageOnly), "{mode}"); + } + assert_eq!(initial_view(&with_modes("FullScreen", Object::Int(1))).non_full_screen, Navigation::PageOnly); + } } diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 26969396a..4972ad689 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -110,6 +110,10 @@ pub struct DocInfo { pub attachments: Vec, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. pub transitions: Vec, + /// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination, + /// named destinations resolved. `None` without one, for an action that goes nowhere in this + /// document (a script, say), or for one that doesn't resolve to a page of it. + pub open_dest: Option<(usize, DestView)>, pub warnings: Vec, } @@ -825,6 +829,25 @@ impl<'a> Inspector<'a> { { info.has_javascript = true; } + info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); + } + + /// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit + /// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array + /// or an action) a destination name or string; a name or string is looked up as a named + /// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and + /// neither has a page number past the end of the document. + fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> { + let dest = match self.resolve(action) { + Object::Dictionary(d) => { + if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) { + return None; + } + d.get(b"D").ok()? + } + other => other, + }; + self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len()) } // ── object helpers ────────────────────────────────────────────────────────────────────── @@ -4114,3 +4137,112 @@ mod open_action_javascript_tests { assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); } } + +#[cfg(test)] +mod open_dest_tests { + use std::sync::Arc; + + use super::{DestView, inspect}; + + /// Three 612×792 pages (objects 3, 4, 5); `catalog` is added to the catalog dictionary and + /// `extra` become objects 6, 7, …. + fn pdf(catalog: &str, extra: &[String]) -> Vec { + let mut objs = + vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>".to_string()]; + for _ in 0..3 { + objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string()); + } + objs.extend(extra.iter().cloned()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + fn open_dest(catalog: &str, extra: &[String]) -> Option<(usize, DestView)> { + inspect(Arc::new(pdf(catalog, extra)), None).expect("inspects").open_dest + } + + const XYZ: DestView = DestView::Xyz { left: Some(100.0), top: Some(500.0), zoom: Some(2.0) }; + + #[test] + fn an_explicit_destination_opens_at_its_page_and_position() { + assert_eq!(open_dest("/OpenAction [5 0 R /XYZ 100 500 2]", &[]), Some((2, XYZ))); + assert_eq!(open_dest("/OpenAction [4 0 R /FitH 400]", &[]), Some((1, DestView::FitH { top: Some(400.0) }))); + // Indirect, and a zoom of 0 keeps the zoom (Table 149). + assert_eq!( + open_dest("/OpenAction 6 0 R", &["[4 0 R /XYZ 0 792 0]".into()]), + Some((1, DestView::Xyz { left: Some(0.0), top: Some(792.0), zoom: None })) + ); + } + + #[test] + fn a_go_to_action_opens_at_its_destination_explicit_or_named() { + assert_eq!(open_dest("/OpenAction << /S /GoTo /D [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); + // A name, through the PDF 1.1 catalog /Dests dictionary (§12.3.2.4). + assert_eq!(open_dest("/OpenAction << /S /GoTo /D /there >> /Dests << /there [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); + // A byte string, through the /Names /Dests name tree, whose value is a dictionary with /D. + assert_eq!( + open_dest( + "/OpenAction << /S /GoTo /D (there) >> /Names << /Dests 6 0 R >>", + &["<< /Kids [7 0 R] >>".into(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".into()] + ), + Some((2, XYZ)) + ); + } + + #[test] + fn an_open_action_given_as_a_name_or_string_is_looked_up() { + assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit))); + assert_eq!( + open_dest("/OpenAction (there) /Names << /Dests << /Names [(there) [5 0 R /FitV 30]] >> >>", &[]), + Some((2, DestView::FitV { left: Some(30.0) })) + ); + } + + #[test] + fn an_open_action_that_goes_nowhere_here_has_no_destination() { + for catalog in [ + "", + "/OpenAction << /S /JavaScript /JS (app.alert(1)) >>", + // A remote go-to's /D is a page of another file. + "/OpenAction << /S /GoToR /F (other.pdf) /D [1 /Fit] >>", + "/OpenAction << /D [4 0 R /Fit] >>", + // An unknown name, and no name dictionaries at all. + "/OpenAction << /S /GoTo /D /missing >> /Dests << /there [4 0 R /Fit] >>", + "/OpenAction /missing", + // A page number past the end, and a page reference that is no page. + "/OpenAction [7 /Fit]", + "/OpenAction [2 0 R /Fit]", + "/OpenAction 42", + ] { + assert_eq!(open_dest(catalog, &[]), None, "{catalog}"); + } + } + + #[test] + fn cyclic_and_deep_named_destinations_end() { + // Names that refer to each other. + assert_eq!(open_dest("/OpenAction /a /Dests << /a /b /b /a >>", &[]), None); + assert_eq!(open_dest("/OpenAction /a /Dests << /a << /D /a >> >>", &[]), None); + // A name tree whose node is its own kid. + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &["<< /Kids [6 0 R] >>".into()]), None); + // A name tree deeper than the depth bound: the leaf past it isn't read. + let depth = 40; + let mut nodes: Vec = (0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 7 + i)).collect(); + nodes.push("<< /Names [(x) [4 0 R /Fit]] >>".into()); + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &nodes), None); + // A shallow one is read. + let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()]; + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit))); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c778bd1be..61a0ca1d3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1284,14 +1284,19 @@ impl PdfCraftApp { } /// Open a document the way it asks to be opened: navigation panel, layout, magnification, - /// page (Document Properties ▸ Initial View). With Always use my default zoom and page - /// layout on, the layout and magnification stay at the defaults the view opened with. + /// page and the place on it (Document Properties ▸ Initial View, `/OpenAction`). With Always + /// use my default zoom and page layout on, the layout and magnification stay at the defaults + /// the view opened with, and the view goes to the opening page's top. fn apply_initial_view(&mut self, index: usize, v: &pdfcraft_engine::InitialView) { use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; let pages = self.session.get(self.views[index].id).map_or(0, |d| d.info.pages.len()); // A right panel the user closed stays closed, whatever the document asks for (#2884). + // A document that asks for full screen gets the panel it names for outside it + // (`/NonFullScreenPageMode`, Table 147), there when the prompt is declined or full + // screen is left. + let navigation = if v.full_screen { v.non_full_screen } else { v.navigation }; if self.active == Some(index) && !self.right_panel_closed { - match v.navigation { + match navigation { N::PageOnly => {} N::Bookmarks => self.right = Some(RightPanel::Bookmarks), N::Pages => self.right = Some(RightPanel::Pages), @@ -1325,8 +1330,19 @@ impl PdfCraftApp { M::FitHeight => view.fit = canvas::Fit::Height, } } - if v.page > 0 && v.page < pages { - view.go_to_page(v.page); + // The opening destination (`/OpenAction`, named destinations resolved): its page, and + // where on it and how large, as a bookmark or link to it would (ISO 32000-2 Table 149). + // With the default view kept, only the page. + let info = self.session.get(view.id).map(|d| &d.info); + match info.and_then(|i| i.open_dest.filter(|(page, _)| *page < pages).map(|d| (i, d))) { + Some((info, (page, dest))) if !self.always_default_view => view.go_to_dest(page, dest, info), + Some((_, (page, _))) => { + if page > 0 { + view.go_to_page(page); + } + } + None if v.page > 0 && v.page < pages => view.go_to_page(v.page), + None => {} } } @@ -1526,16 +1542,18 @@ impl PdfCraftApp { // Read mode opens with the panes closed; the rail still opens them (#932). A right panel // the user closed stays closed (#1590). let opening_mode = self.mode_override.unwrap_or(self.default_mode); + let initial = doc.initial_view(); if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read { self.right = if !doc.info.annotations.is_empty() { Some(RightPanel::Comments) - } else if !doc.info.outline.is_empty() { + // A full-screen document names its panel for outside full screen, and with + // `/NonFullScreenPageMode /UseNone` (the default) no outline is visible (Table 147). + } else if !doc.info.outline.is_empty() && !initial.full_screen { Some(RightPanel::Bookmarks) } else { None }; } - let initial = doc.initial_view(); let view = DocView::new(id, &doc.info, self.view_defaults); self.views.push(view); if activate { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a534850f..0e543a788 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -50,6 +50,7 @@ mod links; mod marks; mod object_selection; mod ocr; +mod open_action; mod organize_drag; mod pageboxes; mod panel_snapshots; diff --git a/crates/ui-egui/tests/it/open_action.rs b/crates/ui-egui/tests/it/open_action.rs new file mode 100644 index 000000000..a6593af70 --- /dev/null +++ b/crates/ui-egui/tests/it/open_action.rs @@ -0,0 +1,191 @@ +//! A document opens where its `/OpenAction` says (ISO 32000-2 §12.3.2.2 Table 149, §12.3.2.4): +//! the page, and the place on it, of an explicit destination, a go-to action's or a named one; +//! and a full-screen document's `/NonFullScreenPageMode` panel (Table 147). + +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use pdfcraft_render::DestView; +use pdfcraft_ui_egui::canvas::Fit; +use pdfcraft_ui_egui::{PdfCraftApp, RightPanel}; + +/// Six Letter (612×792) pages, objects 3 to 8; `catalog` is added to the catalog dictionary. +/// Object 9 is an outline (one bookmark, object 10) that `/Outlines 9 0 R` can use. +fn fixture(catalog: &str) -> Vec { + let n = 6; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + let mut objs = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" "))]; + for _ in 0..n { + objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string()); + } + objs.push("<< /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >>".to_string()); + objs.push("<< /Title (Chapter) /Parent 9 0 R /Dest [6 0 R /Fit] >>".to_string()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn harness(catalog: &str) -> Harness<'static, PdfCraftApp> { + let bytes = fixture(catalog); + 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.set_option("left", "closed").unwrap(); + app.open_bytes("open.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +/// Run frames until rendering has finished and the layout holds still. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..200 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// The page at the top of the window and the user-space height there: where a bookmark made now +/// (⌘B) goes. +fn window_top(h: &mut Harness<'static, PdfCraftApp>) -> (usize, f32) { + h.key_press_modifiers(Modifiers::COMMAND, Key::B); + h.run_steps(3); + let id = h.state().views[0].id; + let outline = &h.state().session.get(id).unwrap().info.outline; + let made = outline.iter().find(|b| b.title == "Untitled").expect("the new bookmark"); + match made.view { + DestView::Xyz { top: Some(top), .. } => (made.page.expect("a page"), top), + other => panic!("expected /XYZ with a top, got {other:?}"), + } +} + +/// The left edge of `page` on screen and its scale (screen points per user-space point). +fn page_left(h: &Harness<'static, PdfCraftApp>, page: usize) -> (f32, f32) { + let rects = h.state().views[0].visible_page_rects(); + let (_, r) = rects.iter().find(|(p, _)| *p == page).expect("the page is shown"); + (r.left(), r.width() / 612.0) +} + +fn near(got: f32, want: f32, what: &str) { + assert!((got - want).abs() < 2.0, "{what}: {got} vs {want}"); +} + +#[test] +fn an_xyz_open_action_puts_its_point_at_the_window_top_left() { + let mut h = harness("/OpenAction [5 0 R /XYZ 100 500 2]"); + assert_eq!(h.state().views[0].current, 2); + assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 2.0)); + // `left`: the page sits 100 user points further left than one opened at left 0. + let (left, scale) = page_left(&h, 2); + let (left0, _) = page_left(&harness("/OpenAction [5 0 R /XYZ 0 500 2]"), 2); + near(left0 - left, 100.0 * scale, "the page moved left by `left`"); + let (page, top) = window_top(&mut h); + assert_eq!(page, 2); + near(top, 500.0, "y 500 is at the window's top edge"); +} + +#[test] +fn a_fith_open_action_puts_its_top_at_the_window_top() { + let mut h = harness("/OpenAction [4 0 R /FitH 400]"); + assert_eq!(h.state().views[0].current, 1); + assert_eq!(h.state().views[0].fit, Fit::Width); + let (page, top) = window_top(&mut h); + assert_eq!(page, 1); + near(top, 400.0, "y 400 is at the window's top edge"); +} + +#[test] +fn a_named_open_destination_opens_its_page() { + for catalog in [ + // PDF 1.1: a name, through the catalog's /Dests. + "/OpenAction << /S /GoTo /D /there >> /Dests << /there [7 0 R /XYZ 0 792 0] >>", + // A string, through the /Names /Dests name tree. + "/OpenAction << /S /GoTo /D (there) >> /Names << /Dests << /Names [(there) [7 0 R /XYZ 0 792 0]] >> >>", + // The destination given as a name, without an action. + "/OpenAction /there /Dests << /there [7 0 R /XYZ 0 792 0] >>", + ] { + let mut h = harness(catalog); + assert_eq!(h.state().views[0].current, 4, "{catalog}"); + // A zoom of 0 keeps the default zoom. + assert_eq!(h.state().views[0].fit, Fit::Width, "{catalog}"); + let (page, top) = window_top(&mut h); + assert_eq!(page, 4, "{catalog}"); + near(top, 792.0, "the page's top is at the window's top edge"); + } + // A name nothing defines opens on the first page. + let h = harness("/OpenAction << /S /GoTo /D /missing >> /Dests << /there [7 0 R /Fit] >>"); + assert_eq!(h.state().views[0].current, 0); +} + +#[test] +fn always_my_default_view_keeps_the_zoom_and_goes_to_the_page_top() { + let bytes = fixture("/OpenAction [5 0 R /XYZ 100 500 2]"); + 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.set_option("left", "closed").unwrap(); + app.always_default_view = true; + app.open_bytes("open.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + settle(&mut h); + assert_eq!(h.state().views[0].current, 2); + assert_eq!(h.state().views[0].fit, Fit::Width); + let (page, top) = window_top(&mut h); + assert_eq!(page, 2); + near(top, 792.0, "the page's top"); +} + +#[test] +fn a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen() { + for (mode, want) in [("UseThumbs", Some(RightPanel::Pages)), ("UseOC", Some(RightPanel::Layers)), ("UseNone", None)] { + let mut h = harness(&format!("/PageMode /FullScreen /ViewerPreferences << /NonFullScreenPageMode /{mode} >>")); + assert!(h.state().pending_full_screen.is_some(), "{mode}: full screen is asked for"); + assert_eq!(h.state().right, want, "{mode}: the panel behind the prompt"); + // Declining it leaves that panel. + let ctx = h.ctx.clone(); + h.state_mut().resolve_full_screen_prompt(&ctx, false); + h.run_steps(2); + assert_eq!((h.state().full_screen, h.state().right), (false, want), "{mode}: declined"); + // Entering and leaving full screen comes back to it. + h.state_mut().apply_full_screen(true); + h.run_steps(2); + h.state_mut().apply_full_screen(false); + h.run_steps(2); + assert_eq!(h.state().right, want, "{mode}: after full screen"); + } + // Without /PageMode /FullScreen the entry means nothing. + let h = harness("/ViewerPreferences << /NonFullScreenPageMode /UseThumbs >>"); + assert_eq!(h.state().right, None); +} + +#[test] +fn a_full_screen_document_with_an_outline_shows_it_only_when_it_asks_to() { + // UseNone (and its absence, the default) shows no outline outside full screen; UseOutlines does. + for (prefs, want) in [ + ("", None), + ("/ViewerPreferences << /NonFullScreenPageMode /UseNone >>", None), + ("/ViewerPreferences << /NonFullScreenPageMode /UseOutlines >>", Some(RightPanel::Bookmarks)), + ] { + let h = harness(&format!("/Outlines 9 0 R /PageMode /FullScreen {prefs}")); + assert_eq!(h.state().right, want, "{prefs}"); + } + // Not in full screen, a document with an outline still opens on it. + let h = harness("/Outlines 9 0 R"); + assert_eq!(h.state().right, Some(RightPanel::Bookmarks)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0c46f2973..23768b797 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3507,8 +3507,8 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)" +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two, and shows page 1 for a named destination" [[feature]] id = "organize.custom-properties"