collective

Browse / github.com/storytold/pdfcraft / patch

M3: a file named with open parameters opens at their destination (#3425)

[Issue report] When open PDF from an external link (pressing F1 on a software) When I pressed F1 in the software to open its manual PDF, I expected it to open the manual PDF to a specific page/location based on where I clicked F1. However, this error appears: "Couldn't read [pdf_name].pdf#nameddest=[reference_link]: The system cannot find the file specified. (os error 2)," and no PDF opens.

Status
open
Score
0.00
Adopters
0 other operators
Author
hazeliscoding (imported from pull request #5049, open)
Base
e9fc890a862ea2d8c895ea32ddecda98580483cd
Size
+309 −13 in 12 files
Review
pending
Shared
2026-10-28
Id
5ad34191e7784df4309d70c9

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/render/src/inspect.rs crates/render/src/inspect/navigation.rs crates/render/src/lib.rs crates/ui-egui/src/app/files.rs crates/ui-egui/src/app/links.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/platform.rs crates/ui-egui/src/platform/open_parameters.rs crates/ui-egui/tests/it/document_links.rs crates/ui-egui/tests/it/open_action.rs parity/acrobat-features.toml

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md  ## Progress log +- **2026-10-26 (M3, #3425):** A file named with open parameters, as help systems start a PDF reader (`manual.pdf#nameddest=Intro`, `#page=12` or `#Intro`), opens at that destination instead of failing to read `manual.pdf#…`: at launch, when another launch hands it over, and in its tab when it is already open. Other parameters are ignored. The app now also applies a GoToR link's named destination, looked up in the target's /Names /Dests or catalog /Dests, instead of warning that it wasn't applied. Estimates unchanged. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs--- a/crates/render/src/inspect.rs+++ b/crates/render/src/inspect.rs@@ -39,6 +39,7 @@ pub use model::{     Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,     PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, };+pub use navigation::named_destination; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; pub use resources::attachment_data;diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs--- a/crates/render/src/inspect/navigation.rs+++ b/crates/render/src/inspect/navigation.rs@@ -1,6 +1,6 @@ #[cfg(test)] use super::core::inspect;-use super::core::spend;+use super::core::{load_options, spend}; use super::model::{DestView, OutlineItem, PageInfo}; use super::*; @@ -440,6 +440,31 @@ fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> {     Ok(()) } +/// The (0-based page, view) of the named destination `name` (ISO 32000-2 §12.3.2.4): a key of+/// the `/Names /Dests` name tree or of the PDF 1.1 catalog `/Dests` dictionary, as a link's or+/// `/OpenAction`'s name resolves. `None` when the document can't be read, has no such name, or+/// the name leads to no page of it. A key matches as written (UTF-8), or as a text string+/// (UTF-16 or PDFDocEncoding) that reads as `name`, the way a link's string destination is read.+/// Read like [`inspect`]: the lazy reader first, lopdf's tolerant one when the lazy reader fails.+pub fn named_destination(bytes: Arc<Vec<u8>>, password: Option<&str>, name: &str) -> Option<(usize, DestView)> {+    let find = |doc: &dyn Structure| {+        let insp = Inspector::new(doc);+        let dest = insp.named_dest(name.as_bytes()).or_else(|| insp.named.iter().find(|(key, _)| text_string(key) == name).map(|(_, dest)| *dest))?;+        insp.dest(dest, 1).filter(|(page, _)| *page < insp.page_ids.len())+    };+    let lazy = catch_unwind(AssertUnwindSafe(|| {+        let doc = LazyStructure::new(bytes.clone(), password).ok()?;+        let found = find(&doc);+        (!doc.failed()).then_some(found)+    }))+    .ok()+    .flatten();+    if let Some(found) = lazy {+        return found;+    }+    catch_unwind(AssertUnwindSafe(|| find(&Document::load_mem_with_options(&bytes, load_options(password)).ok()?))).ok().flatten()+}+ #[cfg(test)] mod open_dest_tests {     use std::sync::Arc;@@ -502,6 +527,31 @@ mod open_dest_tests {         );     } +    fn named(catalog: &str, extra: &[String], name: &str) -> Option<(usize, DestView)> {+        super::named_destination(Arc::new(pdf(catalog, extra)), None, name)+    }++    #[test]+    fn a_named_destination_is_looked_up_by_its_name() {+        // A string key of the /Names /Dests name tree, whose value may be a dictionary with /D.+        let tree = "/Names << /Dests 6 0 R >>";+        let nodes = ["<< /Kids [7 0 R] >>".to_string(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".to_string()];+        assert_eq!(named(tree, &nodes, "there"), Some((2, XYZ)));+        assert_eq!(named(tree, &nodes, "here"), Some((0, DestView::Fit)));+        // A name key of the PDF 1.1 catalog /Dests dictionary.+        assert_eq!(named("/Dests << /old [4 0 R /FitH 400] >>", &[], "old"), Some((1, DestView::FitH { top: Some(400.0) })));+        // A key written as UTF-16 or in PDFDocEncoding matches the text it reads as.+        assert_eq!(named("/Names << /Dests << /Names [<FEFF0074006800E9> [4 0 R /Fit]] >> >>", &[], "thé"), Some((1, DestView::Fit)));+        assert_eq!(named("/Names << /Dests << /Names [(th\\351) [5 0 R /Fit]] >> >>", &[], "thé"), Some((2, DestView::Fit)));+        // A name that isn't there, one past the last page, one that names itself, no names at+        // all, and bytes that aren't a PDF.+        assert_eq!(named(tree, &nodes, "missing"), None);+        assert_eq!(named("/Dests << /far [7 /Fit] >>", &[], "far"), None);+        assert_eq!(named("/Dests << /loop /loop >>", &[], "loop"), None);+        assert_eq!(named("", &[], "there"), None);+        assert_eq!(super::named_destination(Arc::new(b"not a pdf".to_vec()), None, "there"), None);+    }+     #[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)));diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -18,7 +18,8 @@ pub mod transition;  pub use inspect::{     Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,-    PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops,+    PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, named_destination, pretty_date,+    thread_tops, }; pub use pixels::Pixels; pub use raster::{diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs--- a/crates/ui-egui/src/app/files.rs+++ b/crates/ui-egui/src/app/files.rs@@ -533,11 +533,21 @@ impl PdfCraftApp {     /// the file opened, focused its tab, was handed to the background worker, or failed.     #[cfg(not(target_arch = "wasm32"))]     pub(crate) fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome {+        // `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file,+        // shown at that destination once open. A path that names a file is never split.+        let parameters = open_parameters::split(path, |p| std::path::Path::new(p).is_file());+        let (path, after_open) = match &parameters {+            Some((file, destination)) => (file.as_str(), Some(destination.clone())),+            None => (path, None),+        };         // One file, one tab (#2166): a file that is open already comes to the front instead,         // and the tool it was opened for runs on it. One still being opened is left to that.         if let Some(i) = self.tab_showing(path) {             self.startup_superseded = true;             self.active = Some(i);+            if let Some(destination) = after_open {+                self.apply_destination_at(i, destination);+            }             if let Some(command) = then {                 self.execute(command);             }@@ -561,12 +571,16 @@ impl PdfCraftApp {                         return OpenOutcome::Failed;                     }                     if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {-                        // Encrypted: the tool runs once the password is in.+                        // Encrypted: the destination and the tool wait for the password.                         prompt.then = then;+                        prompt.after_open = after_open;                     } else if self.views.len() > before {                         if let Some(doc) = self.views.last().map(|view| view.id) {                             self.watch.known_as(doc, stamp);                         }+                        if let Some(destination) = after_open {+                            self.apply_remote_destination(destination);+                        }                         if let Some(command) = then {                             self.execute(command);                         }@@ -591,7 +605,7 @@ impl PdfCraftApp {                 input: open_worker::Input::Path(std::path::PathBuf::from(path)),                 password: None,                 activate: true,-                after_open: None,+                after_open,                 recovered: None,                 then,             }) {diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs--- a/crates/ui-egui/src/app/links.rs+++ b/crates/ui-egui/src/app/links.rs@@ -66,13 +66,30 @@ impl PdfCraftApp {     }      pub(super) fn apply_remote_destination(&mut self, destination: pdfcraft_render::RemoteDestination) {-        let Some(index) = self.views.len().checked_sub(1) else { return };-        let Some(doc) = self.session.get(self.views[index].id) else { return };+        if let Some(index) = self.views.len().checked_sub(1) {+            self.apply_destination_at(index, destination);+        }+    }++    /// Show `destination` in the tab at `index`: a document just opened from a link or with open+    /// parameters (#3425), or one already open that such a link or launch names.+    pub(super) fn apply_destination_at(&mut self, index: usize, destination: pdfcraft_render::RemoteDestination) {+        let Some(view) = self.views.get_mut(index) else { return };+        let Some(doc) = self.session.get(view.id) else { return };+        let destination = match destination {+            pdfcraft_render::RemoteDestination::Named(name) => {+                match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) {+                    Some((page, dest)) => pdfcraft_render::RemoteDestination::Page { page, view: dest },+                    None => pdfcraft_render::RemoteDestination::Named(name),+                }+            }+            other => other,+        };         let warning = match destination {             pdfcraft_render::RemoteDestination::Default => None,             pdfcraft_render::RemoteDestination::Named(_) => Some("The linked PDF opened, but its named destination was not applied."),-            pdfcraft_render::RemoteDestination::Page { page, view } if page < doc.info.pages.len() => {-                self.views[index].go_to_dest(page, view, &doc.info);+            pdfcraft_render::RemoteDestination::Page { page, view: dest } if page < doc.info.pages.len() => {+                view.go_to_dest(page, dest, &doc.info);                 None             }             pdfcraft_render::RemoteDestination::Page { .. } => Some("The linked PDF opened, but its page destination was out of range."),diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -113,6 +113,8 @@ pub use platform::file_watch; use platform::macos_association; pub use platform::motion; #[cfg(not(target_arch = "wasm32"))]+use platform::open_parameters;+#[cfg(not(target_arch = "wasm32"))] use platform::open_worker; #[cfg(not(target_arch = "wasm32"))] use platform::pickers;diff --git a/crates/ui-egui/src/platform.rs b/crates/ui-egui/src/platform.rs--- a/crates/ui-egui/src/platform.rs+++ b/crates/ui-egui/src/platform.rs@@ -5,6 +5,8 @@ pub mod file_watch; pub(super) mod macos_association; pub mod motion; #[cfg(not(target_arch = "wasm32"))]+pub(super) mod open_parameters;+#[cfg(not(target_arch = "wasm32"))] pub(super) mod open_worker; #[cfg(not(target_arch = "wasm32"))] pub(super) mod pickers;diff --git a/crates/ui-egui/src/platform/open_parameters.rs b/crates/ui-egui/src/platform/open_parameters.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/platform/open_parameters.rs@@ -0,0 +1,101 @@+//! PDF open parameters on a path (#3425): help systems and other programs start a PDF reader with+//! `manual.pdf#nameddest=Intro` or `manual.pdf#page=12` to show one place in a document. The+//! parameters follow Adobe's public "Parameters for Opening PDF Files": `name=value` pairs after a+//! `#`, separated by `&` or `#`. PdfCraft reads `page` (1-based) and `nameddest`, and, as browsers'+//! PDF viewers do, a fragment without `=` as a destination name. Other parameters (`zoom`, `view`,+//! `pagemode`, `search`, …) are ignored, and the file still opens.++use pdfcraft_render::{DestView, RemoteDestination};++/// How many `#` of one path are tried as the end of its file name.+const MAX_CANDIDATES: usize = 32;++/// The file `path` names and the destination its open parameters ask for, when `path` is not a+/// file itself but what comes before one of its `#` is. The first such `#` counts, so a folder or+/// file name may contain `#` too. `None`: open `path` as it is.+pub(crate) fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> {+    if is_file(path) {+        return None;+    }+    let (file, fragment) = path.match_indices('#').take(MAX_CANDIDATES).find_map(|(i, _)| {+        // `#` is one byte, so `i` and `i + 1` are char boundaries.+        let file = path.get(..i)?;+        is_file(file).then(|| (file, path.get(i + 1..).unwrap_or_default()))+    })?;+    Some((file.to_string(), destination(fragment)))+}++/// The destination of a fragment's parameters; the last `page` or `nameddest` wins, as each+/// parameter is applied in turn. A page before the first, or one that isn't a number, is ignored.+fn destination(fragment: &str) -> RemoteDestination {+    if !fragment.contains('=') {+        return if fragment.is_empty() { RemoteDestination::Default } else { RemoteDestination::Named(fragment.to_string()) };+    }+    let mut destination = RemoteDestination::Default;+    for (key, value) in fragment.split(['&', '#']).filter_map(|p| p.split_once('=')) {+        if key.eq_ignore_ascii_case("page") {+            if let Some(page) = value.trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1)) {+                destination = RemoteDestination::Page { page, view: DestView::Top };+            }+        } else if key.eq_ignore_ascii_case("nameddest") && !value.is_empty() {+            destination = RemoteDestination::Named(value.to_string());+        }+    }+    destination+}++#[cfg(test)]+mod tests {+    use super::*;++    fn files(names: &'static [&'static str]) -> impl Fn(&str) -> bool {+        move |p| names.contains(&p)+    }++    fn page(page: usize) -> RemoteDestination {+        RemoteDestination::Page { page, view: DestView::Top }+    }++    fn named(name: &str) -> RemoteDestination {+        RemoteDestination::Named(name.to_string())+    }++    #[test]+    fn a_named_destination_or_page_after_the_file_name_is_split_off() {+        let manual = files(&[r"C:\Help\manual.pdf"]);+        // #3425: DIgSILENT PowerFactory's F1 help.+        assert_eq!(split(r"C:\Help\manual.pdf#nameddest=Load Flow", &manual), Some((r"C:\Help\manual.pdf".into(), named("Load Flow"))));+        assert_eq!(split(r"C:\Help\manual.pdf#page=12", &manual), Some((r"C:\Help\manual.pdf".into(), page(11))));+        // A bare fragment names a destination, as in a browser.+        assert_eq!(split(r"C:\Help\manual.pdf#intro", &manual), Some((r"C:\Help\manual.pdf".into(), named("intro"))));+    }++    #[test]+    fn several_parameters_apply_in_turn_and_unknown_ones_are_ignored() {+        let pdf = files(&["/docs/a.pdf"]);+        assert_eq!(split("/docs/a.pdf#page=3&zoom=200,0,0", &pdf), Some(("/docs/a.pdf".into(), page(2))));+        assert_eq!(split("/docs/a.pdf#zoom=50#nameddest=x", &pdf), Some(("/docs/a.pdf".into(), named("x"))));+        assert_eq!(split("/docs/a.pdf#nameddest=x&page=4", &pdf), Some(("/docs/a.pdf".into(), page(3))));+        assert_eq!(split("/docs/a.pdf#PAGE=2", &pdf), Some(("/docs/a.pdf".into(), page(1))));+        // The file opens at its default view when nothing can be applied.+        for odd in+            ["/docs/a.pdf#", "/docs/a.pdf#page=0", "/docs/a.pdf#page=two", "/docs/a.pdf#page=-1", "/docs/a.pdf#nameddest=", "/docs/a.pdf#view=Fit"]+        {+            assert_eq!(split(odd, &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)), "{odd}");+        }+        assert_eq!(split("/docs/a.pdf#page=99999999999999999999999", &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)));+    }++    #[test]+    fn a_path_that_names_a_file_is_left_alone() {+        // A file whose name has a `#` in it, alone and with parameters of its own.+        let hashed = files(&["/docs/C#.pdf", "/docs/#1/a.pdf"]);+        assert_eq!(split("/docs/C#.pdf", &hashed), None);+        assert_eq!(split("/docs/C#.pdf#page=2", &hashed), Some(("/docs/C#.pdf".into(), page(1))));+        assert_eq!(split("/docs/#1/a.pdf#page=2", &hashed), Some(("/docs/#1/a.pdf".into(), page(1))));+        // Nothing before a `#` is a file: the open fails on the path as given.+        assert_eq!(split("/docs/missing.pdf#page=2", &hashed), None);+        assert_eq!(split("/docs/plain.pdf", &hashed), None);+        assert_eq!(split("#", &hashed), None);+    }+}diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs--- a/crates/ui-egui/tests/it/document_links.rs+++ b/crates/ui-egui/tests/it/document_links.rs@@ -179,6 +179,52 @@ fn a_local_gotor_link_opens_in_a_tab_and_applies_its_destination() {     assert_eq!(h.state().last_opened_url, None, "no URL or external launcher is involved"); } +/// Three 300×400 pages whose catalog /Dests names `there` (page 3) and `here` (page 2).+fn named_pages() -> Vec<u8> {+    let objs = [+        "<< /Type /Catalog /Pages 2 0 R /Dests << /there [5 0 R /Fit] /here [4 0 R /Fit] >> >>",+        "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>",+        "<< /Type /Page /Parent 2 0 R >>",+        "<< /Type /Page /Parent 2 0 R >>",+        "<< /Type /Page /Parent 2 0 R >>",+    ];+    let mut out = b"%PDF-1.7\n".to_vec();+    let mut offs = Vec::new();+    for (i, o) in objs.iter().enumerate() {+        offs.push(out.len());+        out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());+    }+    let x = out.len();+    out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());+    for o in offs {+        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{x}\n%%EOF\n", objs.len() + 1).as_bytes());+    out+}++#[cfg(not(target_arch = "wasm32"))]+#[test]+fn a_gotor_link_to_a_named_destination_opens_at_its_page() {+    let _gpu = gpu();+    let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-named-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    std::fs::write(dir.join("target.pdf"), named_pages()).unwrap();+    for (destination, page) in [("/there", 2), ("(here)", 1)] {+        let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", destination);+        click_remote_link(&mut h);+        support::wait_until(&mut h, "remote document", |app| app.views.len() == 2);+        assert_eq!(h.state().views[1].current, page, "{destination}");+        assert_eq!(h.state().toast, None, "{destination}: applied, so nothing to warn about");+    }+    // A name the target doesn't define opens it at its first page, with the warning.+    let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "/elsewhere");+    click_remote_link(&mut h);+    support::wait_until(&mut h, "remote document", |app| app.views.len() == 2);+    assert_eq!(h.state().views[1].current, 0);+    assert!(h.state().toast.as_ref().is_some_and(|(text, _)| text.contains("named destination was not applied")), "{:?}", h.state().toast);+}+ #[cfg(not(target_arch = "wasm32"))] #[test] fn a_remote_destination_survives_password_retries_and_cancel_opens_no_tab() {diff --git a/crates/ui-egui/tests/it/open_action.rs b/crates/ui-egui/tests/it/open_action.rs--- a/crates/ui-egui/tests/it/open_action.rs+++ b/crates/ui-egui/tests/it/open_action.rs@@ -6,7 +6,7 @@ use egui::{Key, Modifiers}; use egui_kittest::Harness; use pdfcraft_render::DestView; use pdfcraft_ui_egui::canvas::Fit;-use pdfcraft_ui_egui::{PdfCraftApp, RightPanel};+use pdfcraft_ui_egui::{OpenOutcome, 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.@@ -189,3 +189,64 @@ fn a_full_screen_document_with_an_outline_shows_it_only_when_it_asks_to() {     let h = harness("/Outlines 9 0 R");     assert_eq!(h.state().right, Some(RightPanel::Bookmarks)); }++/// Named destinations for open parameters: `intro` (page 2) in the catalog's /Dests and+/// `Load Flow` (page 4) in the /Names /Dests tree. The document itself opens on page 3.+const NAMED: &str = "/OpenAction [5 0 R /Fit] /Dests << /intro [4 0 R /Fit] >> /Names << /Dests << /Names [(Load Flow) [6 0 R /XYZ 0 792 0]] >> >>";++/// `fixture(NAMED)` saved as `manual.pdf` in a folder of its own, whose path is returned.+fn manual(test: &str) -> String {+    let dir = std::env::temp_dir().join(format!("pdfcraft-open-parameters-{test}-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(&dir).unwrap();+    let path = dir.join("manual.pdf");+    std::fs::write(&path, fixture(NAMED)).unwrap();+    path.to_string_lossy().into_owned()+}++#[cfg(not(target_arch = "wasm32"))]+#[test]+fn a_file_named_with_open_parameters_opens_at_their_destination() {+    // #3425: a help system starts PdfCraft with `manual.pdf#nameddest=…`. Files named at launch+    // open before the first frame.+    let path = manual("launch");+    for (parameters, page) in [("#nameddest=Load Flow", 3), ("#nameddest=intro", 1), ("#intro", 1), ("#page=5", 4), ("#zoom=200", 2)] {+        let mut app = PdfCraftApp::new();+        app.set_option("language", "en").unwrap();+        assert_eq!(app.open_path(&format!("{path}{parameters}")), OpenOutcome::Opened, "{parameters}");+        assert_eq!(app.views.len(), 1, "{parameters}");+        assert_eq!(app.views[0].current, page, "{parameters}");+        assert_eq!(app.session.get(app.views[0].id).and_then(|d| d.path.clone()).as_deref(), Some(path.as_str()), "{parameters}: the file itself");+        assert_eq!(app.toast, None, "{parameters}");+    }+    // A name the file doesn't define: it opens where it would have, and says so.+    let mut app = PdfCraftApp::new();+    app.set_option("language", "en").unwrap();+    assert_eq!(app.open_path(&format!("{path}#nameddest=Nowhere")), OpenOutcome::Opened);+    assert_eq!(app.views[0].current, 2);+    assert!(app.toast.as_ref().is_some_and(|(text, _)| text.contains("named destination was not applied")), "{:?}", app.toast);+    // A path that names no file, before a `#` or as a whole, still fails as before.+    let mut app = PdfCraftApp::new();+    app.set_option("language", "en").unwrap();+    assert_eq!(app.open_path(&format!("{path}.missing#page=2")), OpenOutcome::Failed);+    assert!(app.views.is_empty());+}++#[cfg(not(target_arch = "wasm32"))]+#[test]+fn open_parameters_handed_to_the_running_app_show_their_destination() {+    // A second launch hands `manual.pdf#…` to the running app, which opens it in the background.+    let path = manual("running");+    let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {+        let mut app = PdfCraftApp::new();+        app.set_option("language", "en").unwrap();+        app+    });+    h.run_steps(4);+    assert_eq!(h.state_mut().open_path(&format!("{path}#nameddest=Load Flow")), OpenOutcome::Pending);+    crate::support::wait_until(&mut h, "the manual", |app| app.views.len() == 1);+    assert_eq!(h.state().views[0].current, 3);+    // Asked again for a place in the open file, its tab goes there instead of opening it again.+    assert_eq!(h.state_mut().open_path(&format!("{path}#page=2")), OpenOutcome::Focused);+    assert_eq!((h.state().views.len(), h.state().views[0].current), (1, 1));+}diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -34,8 +34,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"]-evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"]-notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"+evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab", "crates/ui-egui/tests/it/open_action.rs::a_file_named_with_open_parameters_opens_at_their_destination", "crates/ui-egui/tests/it/open_action.rs::open_parameters_handed_to_the_running_app_show_their_destination", "crates/ui-egui/src/platform/open_parameters.rs::several_parameters_apply_in_turn_and_unknown_ones_are_ignored"]+notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). A path that ends in open parameters, as help systems pass them (`manual.pdf#nameddest=Intro`, `#page=12`, or `#Intro`; #3425), opens the file at that destination, or goes there in its tab when it is open; other parameters (zoom, view, pagemode, search…) are ignored. Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"  [[feature]] id = "core.repair-broken-files"@@ -1190,8 +1190,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/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.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/tests.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."+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/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.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::a_gotor_link_to_a_named_destination_opens_at_its_page", "crates/render/src/inspect/navigation.rs::a_named_destination_is_looked_up_by_its_name", "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/tests.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. In the app a named remote destination opens at its page and view, looked up in the target's /Names /Dests tree or catalog /Dests (§12.3.2.4); a name the target doesn't define opens it at the default view with a warning, and link_open reports named destinations without applying them; /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"