diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -335,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
 | `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) |
 | `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
 | `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |
-| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
-| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
+| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
+| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
 
 ## Optional build inputs (1)
 
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -3356,30 +3356,30 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa
 sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
 
 [[fetched]]
-file = "text-detection.rten"
-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"
+file = "text-detection.onnx"
+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx"
 title = "Ocrs text detection model"
 author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
 licence = "CC-BY-SA-4.0"
 licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
 licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
 kind = "model"
 usage = "Scan & OCR: finds words on page images (cargo xtask models)"
-sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca"
+sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe"
 
 [[fetched]]
-file = "text-recognition.rten"
-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"
+file = "text-recognition.onnx"
+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx"
 title = "Ocrs text recognition model"
 author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"
-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"
+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)"
 licence = "CC-BY-SA-4.0"
 licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt"
 licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
 kind = "model"
 usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
-sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
+sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
 
 [[build_input]]
 name = "craft-fonts"
diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1174,7 +1174,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
 [[package]]
 name = "craft-keymap"
 version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19"
 dependencies = [
  "egui",
  "serde",
@@ -1184,7 +1184,7 @@ dependencies = [
 [[package]]
 name = "craft-menu"
 version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19"
 dependencies = [
  "craft-keymap",
  "craft-quit",
@@ -1210,7 +1210,7 @@ dependencies = [
 [[package]]
 name = "craft-quit"
 version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19"
 dependencies = [
  "dispatch2",
  "egui",
@@ -1221,7 +1221,7 @@ dependencies = [
 [[package]]
 name = "craft-ui"
 version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19"
 dependencies = [
  "egui",
  "log",
@@ -4340,6 +4340,7 @@ dependencies = [
  "ocrs",
  "pdfcraft-fonts",
  "rten",
+ "tempfile",
  "thiserror 2.0.21",
 ]
 
@@ -5473,6 +5474,7 @@ dependencies = [
  "rten-base",
  "rten-gemm",
  "rten-model-file",
+ "rten-onnx",
  "rten-parallel",
  "rten-shape-inference",
  "rten-simd",
@@ -5522,6 +5524,12 @@ dependencies = [
  "rten-base",
 ]
 
+[[package]]
+name = "rten-onnx"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e"
+
 [[package]]
 name = "rten-parallel"
 version = "0.26.0"
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,12 +13,12 @@ repository = "https://github.com/storytold/pdfcraft"
 
 [workspace.dependencies]
 # Shared frontend components; engines remain independent of egui.
-craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] }
+craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19", features = ["serde"] }
 # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
-craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
+craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19" }
 # One menu definition for the native macOS menu bar and the in-window bar elsewhere.
-craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
-craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false }
+craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19" }
+craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19", default-features = false }
 # Shared system spooler; document composition remains in pdfcraft-print.
 craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
 # Whether a path stored in a document may be opened (GoToR link targets).
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -391,6 +391,9 @@ cargo xtask demo-pdf                              # build the showcase PDF used
 cargo xtask screenshots                           # regenerate every screenshot in this README
 ```
 
+The desktop launcher can open `"manual.pdf#nameddest=chapterTwo"` or `"manual.pdf#page=2"` at a requested help topic or physical page.
+See [launch destinations](docs/launch-destinations.md) for filename escaping, multiple files and limits.
+
 The interface language is chosen in **Menu → Edit → Preferences…** (Command-comma on macOS,
 Ctrl-comma elsewhere, also with no document open; Auto follows the system language;
 see [docs/localization.md](docs/localization.md)) and saved. Japanese covers commands,
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,6 +2,8 @@
 
 **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
 
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M3, #3425: native launch page/named destinations retain per-file navigation and literal filename escaping; estimates unchanged)
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
 > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
 > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
 > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
@@ -199,6 +201,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
 
 ## Progress log
 
+- **2026-10-27 (M3, #3425):** Native launch page/named destinations retain per-file navigation through preparation, password retries and forwarding, with literal filename/URI escaping preserved.
+  Desktop local-PDF links use the same bounded named lookup; other opening parameters and estimates remain unchanged.
+
+- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
 - **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.
@@ -676,12 +682,18 @@ Newest first: the date, what moved, and the new overall estimate.
 
 ## Revision history
 
+**2026-10-27 | minor:** M3, #3425: native launch arguments separate local filenames from ordered page/named destinations.
+Navigation follows its file through forwarding, preparation, password retries and focusing an existing tab.
+Named lookup is bounded and shared with desktop local-PDF links.
+Other opening parameters, the destinations panel and estimates remain unchanged.
+
 **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
 
 **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
 | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
 | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
 | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -177,8 +177,22 @@ fn rejoin_split_paths(args: Vec<String>, exists: impl Fn(&str) -> bool) -> Vec<S
             i += 1;
             continue;
         }
-        // The longest run starting here that names a file; capped so a long list stays cheap.
-        let end = (i + 2..=args.len().min(i + 32)).rev().find(|&j| args.get(i..j).is_some_and(|run| exists(&run.join(" "))));
+        // Prefer the longest real literal path, or the first completed navigation target.
+        // Extending a named value with another independent file still names the same base
+        // PDF, so navigation-bearing probes must not overwrite the first match.
+        let mut literal = None;
+        let mut navigation = None;
+        for end in i + 2..=args.len().min(i + 32) {
+            let Some(candidate) = args.get(i..end).map(|run| run.join(" ")) else { continue };
+            if exists(&candidate) {
+                if pdfcraft_ui_egui::NativeOpenTarget::from_arg(&candidate).is_ok_and(|target| target.has_launch_parameters()) {
+                    navigation.get_or_insert(end);
+                } else {
+                    literal = Some(end);
+                }
+            }
+        }
+        let end = literal.or(navigation);
         match end.and_then(|j| Some((j, args.get(i..j)?.join(" ")))) {
             Some((j, joined)) => {
                 out.push(joined);
@@ -236,10 +250,17 @@ fn main() -> eframe::Result {
                 let value = args.next().unwrap_or_default();
                 options.push((flag.trim_start_matches("--").to_string(), value));
             }
-            _ => files.push(path_from_arg(a)),
+            _ => files.push(a),
         }
     }
-    let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
+    let files = rejoin_split_paths(files, |p| {
+        if create_images {
+            std::path::Path::new(&path_from_arg(p.to_string())).is_file()
+        } else {
+            pdfcraft_ui_egui::NativeOpenTarget::from_arg(p).is_ok_and(|target| std::path::Path::new(target.path()).is_file())
+        }
+    });
+    let files = if create_images { files.into_iter().map(path_from_arg).collect() } else { files };
     let integrated = cfg!(target_os = "macos");
     // GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
     let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
@@ -263,8 +284,12 @@ fn main() -> eframe::Result {
     let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
     // Absolute for this instance too: links relative to a document resolve against its folder,
     // never the working directory.
-    let files = single_instance::absolute(&files);
-    let instance = match single_instance::claim(&files, may_hand_off) {
+    let files = if create_images {
+        files.into_iter().map(|path| std::path::absolute(&path).map(|p| p.to_string_lossy().into_owned()).unwrap_or(path)).collect()
+    } else {
+        single_instance::absolute(&files)
+    };
+    let instance = match single_instance::claim(&files, may_hand_off && single_instance::can_hand_off(&files)) {
         single_instance::Claim::HandedOff => return Ok(()),
         single_instance::Claim::Primary(server) => Some(server),
         single_instance::Claim::Alone => None,
@@ -474,9 +499,16 @@ fn app_creator<'a>(
         } else {
             // With the preference on, last session's files come back first; files named on
             // the command line open after them, in front (#442).
-            app.reopen_last_files(&files);
-            for f in files {
-                app.open_path(&f);
+            let targets: Vec<_> = files.iter().map(|file| pdfcraft_ui_egui::NativeOpenTarget::from_arg(file)).collect();
+            let paths: Vec<_> = targets.iter().filter_map(|target| target.as_ref().ok().map(|target| target.path().to_string())).collect();
+            app.reopen_last_files(&paths);
+            for target in targets {
+                match target {
+                    Ok(target) => {
+                        app.open_target(target);
+                    }
+                    Err(error) => app.notify(error),
+                }
             }
         }
         for (k, v) in options {
diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs
--- a/apps/pdfcraft/src/single_instance.rs
+++ b/apps/pdfcraft/src/single_instance.rs
@@ -12,8 +12,9 @@
 //! only the user can reach, so other accounts can't create, replace or reach it: nobody else can
 //! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is
 //! the app's own and is shared by every run of it, so the handoff works there too. The exchange is
-//! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the
-//! paths exactly as File ▸ Open would.
+//! one line each way, a JSON request naming canonical local file URIs and `ok`, and the running
+//! app opens the launch arguments through the same parser as a new window.
+//! Legacy `open` requests remain literal paths; `open_arguments` preserves launcher semantics.
 //!
 //! Every failure falls back to the old behaviour: a launch that can't hand its files over within
 //! a few seconds opens its own window. `--new-window`, any other option, or no files at all also
@@ -57,7 +58,7 @@ pub enum Claim {
 /// Files that later launches handed over, and how to wake the UI for them.
 #[derive(Default)]
 struct Inbox {
-    paths: Vec<String>,
+    events: Vec<OsEvent>,
     wake: Option<Box<dyn Fn() + Send>>,
 }
 
@@ -75,9 +76,9 @@ impl Server {
 
     /// Queue files from another launch and wake the UI.
     #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
-    fn deliver(&self, paths: Vec<String>) {
+    fn deliver_event(&self, event: OsEvent) {
         let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
-        inbox.paths.extend(paths);
+        inbox.events.push(event);
         if let Some(wake) = &inbox.wake {
             wake();
         }
@@ -94,8 +95,8 @@ impl Server {
         let inbox = Arc::clone(&self.inbox);
         let ctx = ctx.clone();
         Box::new(move || {
-            let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths);
-            if paths.is_empty() {
+            let events = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).events);
+            if events.is_empty() {
                 return Vec::new();
             }
             ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
@@ -103,33 +104,54 @@ impl Server {
             if native_wayland {
                 ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
             }
-            vec![OsEvent::Open(paths)]
+            events
         })
     }
 }
 
-/// The request a launch sends: `{"open":[…]}` and a newline.
+/// New peers use a distinct field so older peers refuse launcher syntax and open a new window.
 #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
 pub fn request(paths: &[String]) -> String {
-    let mut line = serde_json::json!({ "open": paths }).to_string();
+    let mut line = serde_json::json!({ "open_arguments": paths }).to_string();
     line.push('\n');
     line
 }
 
-/// The paths a request names, or `None` when it isn't a request: anything but one JSON object
-/// whose `open` is a non-empty list of non-empty strings.
+/// Keep legacy filesystem paths and launcher arguments distinct, refusing ambiguous requests.
 #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
-pub fn parse_request(line: &str) -> Option<Vec<String>> {
+pub fn parse_request(line: &str) -> Option<OsEvent> {
     let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
-    let open = value.get("open")?.as_array()?;
+    let object = value.as_object()?;
+    let (open, arguments) = match (object.get("open"), object.get("open_arguments")) {
+        (Some(open), None) => (open.as_array()?, false),
+        (None, Some(open)) => (open.as_array()?, true),
+        _ => return None,
+    };
     let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Option<Vec<String>>>()?;
-    (!paths.is_empty()).then_some(paths)
+    if paths.is_empty() {
+        return None;
+    }
+    Some(if arguments { OsEvent::OpenArguments(paths) } else { OsEvent::Open(paths) })
 }
 
 /// File arguments made absolute against this launch's working folder, which the running app
-/// doesn't share. A path that can't be made absolute is sent as it is.
+/// doesn't share. Invalid arguments remain for this launch to report, never for handoff.
 pub fn absolute(paths: &[String]) -> Vec<String> {
-    paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect()
+    paths
+        .iter()
+        .map(|argument| {
+            pdfcraft_ui_egui::NativeOpenTarget::from_arg(argument).and_then(|target| target.absolute_argument()).unwrap_or_else(|_| argument.clone())
+        })
+        .collect()
+}
+
+/// A failed normalization/encoding must never send a sender-relative fallback to another app.
+pub fn can_hand_off(arguments: &[String]) -> bool {
+    arguments.iter().all(|argument| {
+        pdfcraft_ui_egui::NativeOpenTarget::from_arg(argument)
+            .and_then(|target| target.absolute_argument())
+            .is_ok_and(|canonical| canonical == *argument)
+    }) && request(arguments).len() <= MAX_REQUEST as usize
 }
 
 /// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
@@ -342,8 +364,8 @@ fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> {
     if line.is_empty() {
         return Ok(());
     }
-    let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
-    server.deliver(paths);
+    let event = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
+    server.deliver_event(event);
     let mut writer = stream;
     writer.write_all(b"ok\n")
 }
@@ -357,7 +379,7 @@ mod tests {
         let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()];
         let line = request(&paths);
         assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line");
-        assert_eq!(parse_request(&line), Some(paths));
+        assert_eq!(parse_request(&line), Some(OsEvent::OpenArguments(paths)));
     }
 
     #[test]
@@ -370,9 +392,15 @@ mod tests {
     #[test]
     fn relative_paths_are_made_absolute_against_this_launch() {
         let out = absolute(&["Quote.pdf".to_string()]);
-        assert_eq!(out, vec![std::env::current_dir().unwrap().join("Quote.pdf").to_string_lossy().into_owned()]);
+        assert_eq!(
+            pdfcraft_ui_egui::NativeOpenTarget::from_arg(&out[0]).unwrap().path(),
+            std::env::current_dir().unwrap().join("Quote.pdf").to_str().unwrap()
+        );
+        assert!(can_hand_off(&out));
+        assert!(!can_hand_off(&["Quote.pdf".to_string()]));
+        assert!(!can_hand_off(&["bad\0path.pdf".to_string()]));
         let already = std::env::current_dir().unwrap().join("x.pdf").to_string_lossy().into_owned();
-        assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]);
+        assert_eq!(pdfcraft_ui_egui::NativeOpenTarget::from_arg(&absolute(std::slice::from_ref(&already))[0]).unwrap().path(), already);
     }
 
     #[test]
@@ -397,12 +425,12 @@ mod tests {
     fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() {
         let server = Server::new();
         // Before the window exists: queued, nothing to wake yet.
-        server.deliver(vec!["a.pdf".into()]);
+        server.deliver_event(OsEvent::Open(vec!["a.pdf".into()]));
         let ctx = egui::Context::default();
         let mut poll = server.connect(&ctx, false);
-        server.deliver(vec!["b.pdf".into()]);
+        server.deliver_event(OsEvent::OpenArguments(vec!["b.pdf".into()]));
         let events = poll();
-        assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"]));
+        assert_eq!(events, vec![OsEvent::Open(vec!["a.pdf".into()]), OsEvent::OpenArguments(vec!["b.pdf".into()])]);
         assert!(poll().is_empty(), "each file is delivered once");
     }
 
@@ -444,7 +472,7 @@ mod tests {
         let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") };
         let mut poll = first.connect(&egui::Context::default(), false);
         assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
-        assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
+        assert!(matches!(poll().as_slice(), [OsEvent::OpenArguments(p)] if p == &["C:\\a.pdf"]));
         // A launch with options keeps its own window and doesn't take over the socket.
         assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone));
         assert!(poll().is_empty());
@@ -484,4 +512,44 @@ mod tests {
         assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind");
         assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_)));
     }
+    #[test]
+    fn file_uris_keep_raw_escapes_while_plain_launch_paths_use_the_senders_folder() {
+        let uri = "file:///tmp/manual%23draft.pdf#nameddest=chapter%26two".to_string();
+        let plain = "manual with spaces.pdf#page=2".to_string();
+        let normalized = absolute(&[plain.clone(), uri.clone()]);
+        let received = pdfcraft_ui_egui::NativeOpenTarget::from_arg(&normalized[0]).unwrap();
+        assert_eq!(std::path::Path::new(received.path()), std::path::absolute("manual with spaces.pdf").unwrap());
+        assert_eq!(received.destinations(), pdfcraft_ui_egui::NativeOpenTarget::from_arg(&plain).unwrap().destinations());
+        assert_eq!(normalized[1], uri);
+    }
+    #[test]
+    fn native_launch_protocol_is_not_accepted_as_legacy_literal_paths() {
+        let line = request(&["file:///tmp/manual%23draft.pdf#page=2".into()]);
+        let legacy: serde_json::Value = serde_json::from_str(&line).unwrap();
+        assert!(legacy.get("open").is_none(), "older peers must reject launcher semantics instead of opening them as literal paths");
+    }
+
+    #[test]
+    fn native_launch_legacy_delivery_keeps_literal_path_provenance() {
+        let server = Server::new();
+        let context = egui::Context::default();
+        let mut poll = server.connect(&context, false);
+        server.deliver_event(parse_request(r#"{"open":["manual.pdf#page=2"]}"#).unwrap());
+        assert!(matches!(poll().as_slice(), [OsEvent::Open(paths)] if paths == &["manual.pdf#page=2"]));
+    }
+
+    #[test]
+    fn launch_request_rejects_ambiguous_and_invalid_new_schemas() {
+        for line in [
+            r#"{"open":[],"open_arguments":["a.pdf"]}"#,
+            r#"{"open":["a.pdf"],"open_arguments":null}"#,
+            r#"{"open_arguments":[]}"#,
+            r#"{"open_arguments":[""]}"#,
+            r#"{"open_arguments":[3]}"#,
+        ] {
+            assert_eq!(parse_request(line), None, "{line}");
+        }
+        let files = vec!["a.pdf#page=2".to_string()];
+        assert_eq!(parse_request(&request(&files)), Some(OsEvent::OpenArguments(files)));
+    }
 }
diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs
--- a/apps/pdfcraft/src/tests.rs
+++ b/apps/pdfcraft/src/tests.rs
@@ -684,3 +684,42 @@ fn start_up_and_the_panic_hook_never_use_eprintln() {
         offenders.join("\n")
     );
 }
+
+#[test]
+fn native_launch_rejoining_keeps_a_following_independent_file() {
+    let dir = std::env::temp_dir().join(format!("pdfcraft-launch-rejoin-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    let manual = dir.join("manual with spaces.pdf");
+    let other = dir.join("other.pdf");
+    std::fs::write(&manual, b"original fixture").unwrap();
+    std::fs::write(&other, b"original fixture").unwrap();
+    let args =
+        vec![dir.join("manual").to_string_lossy().into_owned(), "with spaces.pdf#nameddest=there".into(), other.to_string_lossy().into_owned()];
+    let joined = super::rejoin_split_paths(args, |p| {
+        pdfcraft_ui_egui::NativeOpenTarget::from_arg(p).is_ok_and(|target| std::path::Path::new(target.path()).is_file())
+    });
+    assert_eq!(joined, [format!("{}#nameddest=there", manual.display()), other.to_string_lossy().into_owned()]);
+    let literal = format!("{}#nameddest=there more.pdf", manual.display());
+    std::fs::write(&literal, b"original literal fixture").unwrap();
+    let args = vec![dir.join("manual").to_string_lossy().into_owned(), "with spaces.pdf#nameddest=there".into(), "more.pdf".into()];
+    let joined = super::rejoin_split_paths(args, |p| {
+        pdfcraft_ui_egui::NativeOpenTarget::from_arg(p).is_ok_and(|target| std::path::Path::new(target.path()).is_file())
+    });
+    assert_eq!(joined, [literal], "a longer real literal filename still wins");
+    let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn native_launch_absolutization_keeps_destination_path_characters() {
+    let file = std::env::temp_dir().join(format!("pdfcraft-launch-absolute-{}.pdf", std::process::id()));
+    std::fs::write(&file, b"original fixture").unwrap();
+    for name in ["section//target", "section/../target"] {
+        let argument = format!("{}#nameddest={name}", file.display());
+        let original = pdfcraft_ui_egui::NativeOpenTarget::from_arg(&argument).unwrap();
+        let normalized = super::single_instance::absolute(&[argument]);
+        let received = pdfcraft_ui_egui::NativeOpenTarget::from_arg(&normalized[0]).unwrap();
+        assert_eq!(std::path::Path::new(received.path()), file.as_path());
+        assert_eq!(received.destinations(), original.destinations());
+    }
+    let _ = std::fs::remove_file(file);
+}
diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml
--- a/crates/ocr/Cargo.toml
+++ b/crates/ocr/Cargo.toml
@@ -13,5 +13,8 @@ workspace = true
 [dependencies]
 pdfcraft-fonts = { workspace = true }
 ocrs = { version = "0.13.1", default-features = false, features = ["rten"] }
-rten = { version = "0.26.0", default-features = false, features = ["rten_format"] }
+rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] }
 thiserror.workspace = true
+
+[dev-dependencies]
+tempfile = "3.27"
diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs
--- a/crates/ocr/src/lib.rs
+++ b/crates/ocr/src/lib.rs
@@ -15,8 +15,8 @@ use std::path::{Path, PathBuf};
 pub use pdfcraft_fonts::helvetica_width;
 
 /// The model files, as named in ATTRIBUTION.toml.
-pub const DETECTION_MODEL: &str = "text-detection.rten";
-pub const RECOGNITION_MODEL: &str = "text-recognition.rten";
+pub const DETECTION_MODEL: &str = "text-detection.onnx";
+pub const RECOGNITION_MODEL: &str = "text-recognition.onnx";
 
 /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its
 /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had
@@ -48,10 +48,13 @@ pub struct Models {
 }
 
 impl Models {
-    /// The models in `dir`, if both files are there.
+    /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files.
+    /// An incomplete update never mixes formats or hides an intact legacy pair.
     pub fn in_dir(dir: &Path) -> Option<Models> {
-        let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) };
-        (m.detection.is_file() && m.recognition.is_file()).then_some(m)
+        [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| {
+            let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) };
+            (m.detection.is_file() && m.recognition.is_file()).then_some(m)
+        })
     }
 
     /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them
@@ -321,6 +324,63 @@ mod tests {
         assert_eq!(w.up, [-10.0, 0.0]);
     }
 
+    /// The current ONNX pair wins when an installation also retains both legacy models.
+    #[test]
+    fn model_discovery_prefers_the_complete_onnx_pair() {
+        let dir = tempfile::tempdir().unwrap();
+        for file in ["text-detection.onnx", "text-recognition.onnx"] {
+            std::fs::write(dir.path().join(file), b"model").unwrap();
+        }
+        let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") });
+        assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient");
+        for file in ["text-detection.rten", "text-recognition.rten"] {
+            std::fs::write(dir.path().join(file), b"model").unwrap();
+        }
+        assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models");
+    }
+
+    /// An interrupted ONNX update still permits an intact legacy pair in the same directory.
+    #[test]
+    fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() {
+        for current in ["text-detection.onnx", "text-recognition.onnx"] {
+            let dir = tempfile::tempdir().unwrap();
+            for file in [current, "text-detection.rten", "text-recognition.rten"] {
+                std::fs::write(dir.path().join(file), b"model").unwrap();
+            }
+            assert_eq!(
+                Models::in_dir(dir.path()),
+                Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }),
+                "{current} alone must not hide a complete legacy pair"
+            );
+        }
+    }
+
+    /// Detection and recognition must come from one complete format pair, never a mixed update.
+    #[test]
+    fn model_discovery_refuses_mixed_format_pairs() {
+        for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] {
+            let dir = tempfile::tempdir().unwrap();
+            for file in pair {
+                std::fs::write(dir.path().join(file), b"model").unwrap();
+            }
+            assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair");
+        }
+    }
+
+    /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair.
+    #[test]
+    fn model_discovery_keeps_legacy_only_installations() {
+        let dir = tempfile::tempdir().unwrap();
+        assert_eq!(Models::in_dir(dir.path()), None);
+        std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap();
+        assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete");
+        std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap();
+        assert_eq!(
+            Models::in_dir(dir.path()),
+            Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") })
+        );
+    }
+
     /// #103: every release package's layout puts the models where the installed app looks.
     /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}.
     #[test]
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
@@ -5,7 +5,7 @@
 //! remains the compatibility fallback. When neither can inspect a renderable file, panels
 //! are empty and `warnings` says why.
 
-use std::cell::OnceCell;
+use std::cell::{Cell, OnceCell};
 use std::collections::hash_map::Entry;
 use std::collections::{BTreeMap, HashMap, HashSet};
 use std::sync::Arc;
@@ -32,7 +32,7 @@ mod resources;
 #[cfg(test)]
 use annotations::{MAX_ANNOTATION_BYTES, MAX_LINK_ATTACHMENT_TEXT_BYTES, MAX_REMOTE_FILE_SPEC_BYTES, retained};
 use core::Inspector;
-pub use core::{check_pages, inspect, pretty_date};
+pub use core::{check_pages, inspect, named_destination, named_destinations, pretty_date};
 #[cfg(test)]
 use core::{inspect_structure, spend};
 pub use model::{
@@ -87,3 +87,6 @@ pub struct DocInfo {
 
 #[cfg(test)]
 mod tests;
+
+/// One result in an ordered, bounded named-destination batch.
+pub type NamedDestinationResult = Result<Option<(usize, DestView)>, OpenError>;
diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs
--- a/crates/render/src/inspect/core.rs
+++ b/crates/render/src/inspect/core.rs
@@ -110,6 +110,79 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
     Ok(info)
 }
 
+/// Resolve one named destination through the bounded batch resolver.
+pub fn named_destination(bytes: Arc<Vec<u8>>, password: Option<&str>, name: &str) -> NamedDestinationResult {
+    named_destinations(bytes, password, &[name])?.into_iter().next().unwrap_or(Ok(None))
+}
+
+/// Resolve up to 64 names with one temporary reader and cumulative traversal/decoding budgets.
+/// Per-name errors retain earlier successful results; exact raw key bytes take precedence over
+/// PDF text decoding. No map of every destination is retained for these queries.
+pub fn named_destinations(bytes: Arc<Vec<u8>>, password: Option<&str>, names: &[&str]) -> Result<Vec<NamedDestinationResult>, OpenError> {
+    if names.len() > 64 {
+        return Err(OpenError::Invalid("too many destination queries".into()));
+    }
+    if names.is_empty() {
+        return Ok(Vec::new());
+    }
+    catch_unwind(AssertUnwindSafe(|| {
+        let lazy = if let Ok(doc) = LazyStructure::new(bytes.clone(), password) {
+            let results = query_destinations(&Inspector::without_named_index(&doc), names);
+            if !doc.failed() {
+                return Ok(results);
+            }
+            Some(results)
+        } else {
+            None
+        };
+        let compatibility = catch_unwind(AssertUnwindSafe(|| {
+            let doc = Document::load_mem_with_options(&bytes, load_options(password)).map_err(|e| OpenError::Invalid(e.to_string()))?;
+            Ok::<_, OpenError>(query_destinations(&Inspector::without_named_index(&doc), names))
+        }))
+        .map_err(|p| OpenError::Invalid(format!("the destination parser crashed: {}", crate::raster::panic_message(&p))))
+        .and_then(|result| result);
+        match compatibility {
+            Ok(fallback) => Ok(if let Some(lazy) = lazy {
+                fallback
+                    .into_iter()
+                    .zip(lazy)
+                    .map(
+                        |(fallback, original)| {
+                            if matches!(&original, Ok(Some(_))) && !matches!(&fallback, Ok(Some(_))) { original } else { fallback }
+                        },
+                    )
+                    .collect()
+            } else {
+                fallback
+            }),
+            Err(error) => lazy.ok_or(error),
+        }
+    }))
+    .map_err(|p| OpenError::Invalid(format!("the destination parser crashed: {}", crate::raster::panic_message(&p))))?
+}
+
+fn query_destinations(inspector: &Inspector<'_>, names: &[&str]) -> Vec<NamedDestinationResult> {
+    names
+        .iter()
+        .map(|name| {
+            if name.len() > 1024 || name.contains('\0') {
+                return Err(OpenError::Invalid("the destination name is too long or contains NUL".into()));
+            }
+            let result = catch_unwind(AssertUnwindSafe(|| inspector.destination_named(name)));
+            match result {
+                Ok(destination) if inspector.query_work.get().complete => Ok(destination),
+                Ok(_) => Err(OpenError::Invalid("the destination tree could not be searched within its safety limits".into())),
+                Err(panic) => {
+                    let mut work = inspector.query_work.get();
+                    work.complete = false;
+                    inspector.query_work.set(work);
+                    Err(OpenError::Invalid(format!("the destination parser crashed: {}", crate::raster::panic_message(&panic))))
+                }
+            }
+        })
+        .collect()
+}
+
 // Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
 pub(super) fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
     let structure = catch_unwind(AssertUnwindSafe(|| {
@@ -136,7 +209,10 @@ pub(super) struct Inspector<'a> {
     pub(super) page_index: HashMap<ObjectId, usize>,
     /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once.
     /// Looking each name up by walking the tree was O(links × names): minutes on manuals.
-    pub(super) named: HashMap<Vec<u8>, &'a Object>,
+    pub(super) named: Option<HashMap<Vec<u8>, &'a Object>>,
+    pub(super) named_index_limited: bool,
+    pub(super) query_work: Cell<super::navigation::NameWalk>,
+    pub(super) query_decode_left: Cell<usize>,
     /// The page of each annotation (the first page that lists it), built when a field's widget
     /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields ×
     /// annotations): a second on a form of 8,000 fields.
@@ -145,20 +221,52 @@ pub(super) struct Inspector<'a> {
 
 impl<'a> Inspector<'a> {
     pub(super) fn new(doc: &'a dyn Structure) -> Self {
-        let page_ids = doc.page_ids();
-        let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect();
-        let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() };
+        let mut me = Self::without_named_index(doc);
         let mut named = HashMap::new();
-        if let Some(tree) =
-            doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o))
-        {
-            me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named);
+        match me.destination_tree() {
+            Ok(Some(tree)) => {
+                let mut work = super::navigation::NameWalk::new();
+                let mut key_bytes = super::navigation::NAME_DECODE_BYTES;
+                let mut key_limit = false;
+                me.visit_names(tree, &mut HashSet::new(), 0, &mut work, &mut |key, value| {
+                    if let Some(left) = key_bytes.checked_sub(key.len()) {
+                        key_bytes = left;
+                        named.entry(key.to_vec()).or_insert(value);
+                        None
+                    } else {
+                        key_limit = true;
+                        Some(value)
+                    }
+                });
+                me.named_index_limited = !work.complete || key_limit;
+            }
+            Ok(None) => {}
+            Err(()) => me.named_index_limited = true,
         }
-        me.named = named;
+        me.named = Some(named);
         me
     }
 
+    /// One on-demand query must not rebuild and retain every destination name.
+    pub(super) fn without_named_index(doc: &'a dyn Structure) -> Self {
+        let page_ids = doc.page_ids();
+        let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect();
+        Self {
+            doc,
+            page_ids,
+            page_index,
+            named: None,
+            named_index_limited: false,
+            query_work: Cell::new(super::navigation::NameWalk::new()),
+            query_decode_left: Cell::new(super::navigation::NAME_DECODE_BYTES),
+            annotation_pages: OnceCell::new(),
+        }
+    }
+
     pub(super) fn fill(&self, info: &mut DocInfo) {
+        if self.named_index_limited {
+            info.warnings.push("Some named destinations could not be indexed within the safe traversal limits.".into());
+        }
         // lopdf drops /Encrypt from the trailer once it has decrypted the file.
         info.encrypted = self.doc.encrypted();
         if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) {
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
@@ -11,6 +11,36 @@ pub(super) const OUTLINE_WINDOW: usize = 10_000;
 /// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out.
 pub(super) const OUTLINE_DEPTH: usize = 32;
 
+pub(super) const NAME_TREE_WORK: usize = 1 << 20;
+
+pub(super) const NAME_DECODE_BYTES: usize = 16 << 20;
+
+#[derive(Clone, Copy)]
+pub(super) struct NameWalk {
+    pub(super) remaining: usize,
+    pub(super) complete: bool,
+}
+impl NameWalk {
+    pub(super) fn new() -> Self {
+        Self { remaining: NAME_TREE_WORK, complete: true }
+    }
+    fn spend(&mut self) -> bool {
+        if !self.complete {
+            return false;
+        }
+        match self.remaining.checked_sub(1) {
+            Some(remaining) => {
+                self.remaining = remaining;
+                true
+            }
+            None => {
+                self.complete = false;
+                false
+            }
+        }
+    }
+}
+
 impl<'a> Inspector<'a> {
     /// 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
@@ -60,45 +90,226 @@ impl<'a> Inspector<'a> {
         }
     }
 
+    pub(super) fn destination_tree(&self) -> Result<Option<&'a Dictionary>, ()> {
+        let catalog = self.doc.catalog().map_err(|_| ())?;
+        let Ok(names) = catalog.get(b"Names") else { return Ok(None) };
+        let names = self.dict(names).ok_or(())?;
+        let Ok(tree) = names.get(b"Dests") else { return Ok(None) };
+        self.dict(tree).map(Some).ok_or(())
+    }
+
+    fn lookup_named(&self, key: &[u8]) -> Result<Option<&'a Object>, ()> {
+        if let Some(named) = &self.named {
+            if let Some(value) = named.get(key) {
+                return Ok(Some(value));
+            }
+            if self.named_index_limited {
+                return Err(());
+            }
+        } else {
+            if key.len() > 4098 {
+                self.limit_name_query();
+                return Err(());
+            }
+            let mut walk = self.query_work.get();
+            if !walk.complete {
+                return Err(());
+            }
+            let tree = match self.destination_tree() {
+                Ok(tree) => tree,
+                Err(()) => {
+                    walk.complete = false;
+                    self.query_work.set(walk);
+                    return Err(());
+                }
+            };
+            if let Some(tree) = tree {
+                let mut byte_limited = false;
+                let found = self.visit_names(tree, &mut HashSet::new(), 0, &mut walk, &mut |name, value| {
+                    if name.len() != key.len() {
+                        return None;
+                    }
+                    if !self.spend_name_bytes(key.len()) {
+                        byte_limited = true;
+                        return Some(value);
+                    }
+                    (name == key).then_some(value)
+                });
+                walk.complete &= !byte_limited;
+                self.query_work.set(walk);
+                if !walk.complete {
+                    return Err(());
+                }
+                if found.is_some() {
+                    return Ok(found);
+                }
+            }
+        }
+        let catalog = self.doc.catalog().map_err(|_| ())?;
+        let Ok(legacy) = catalog.get(b"Dests") else { return Ok(None) };
+        // Dictionary binary search can compare this key once per usize address bit.
+        // Keep ordinary indexed inspection unchanged; only on-demand queries share this cap.
+        if self.named.is_none() && !self.spend_name_bytes(key.len().saturating_mul(usize::BITS as usize)) {
+            return Err(());
+        }
+        Ok(self.dict(legacy).ok_or(())?.get(key).ok())
+    }
+
     fn named_dest(&self, key: &[u8]) -> Option<&'a Object> {
-        let catalog = self.doc.catalog().ok()?;
-        if let Some(v) = self.named.get(key) {
-            return Some(v);
+        self.lookup_named(key).ok().flatten()
+    }
+
+    fn limit_name_query(&self) {
+        let mut work = self.query_work.get();
+        work.complete = false;
+        self.query_work.set(work);
+    }
+
+    fn spend_name_bytes(&self, cost: usize) -> bool {
+        if let Some(left) = self.query_decode_left.get().checked_sub(cost) {
+            self.query_decode_left.set(left);
+            true
+        } else {
+            self.limit_name_query();
+            false
+        }
+    }
+
+    fn spend_name_decode(&self, key: &[u8]) -> bool {
+        if key.len() > 4098 {
+            self.limit_name_query();
+            return false;
         }
-        // PDF 1.1 style /Dests dictionary.
-        catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok())
+        // The length cap makes this multiplication infallible, including on wasm32.
+        self.spend_name_bytes(key.len() * 3)
     }
 
-    /// A name tree's leaves keyed by their raw key bytes (first occurrence wins).
-    pub(super) fn name_tree_raw(
+    pub(super) fn destination_named(&self, name: &str) -> Option<(usize, DestView)> {
+        let raw = match self.lookup_named(name.as_bytes()) {
+            Ok(raw) => raw,
+            Err(()) => {
+                let mut walk = self.query_work.get();
+                walk.complete = false;
+                self.query_work.set(walk);
+                return None;
+            }
+        };
+        let value = raw.or_else(|| {
+            let mut walk = self.query_work.get();
+            let tree = match self.destination_tree() {
+                Ok(tree) => tree,
+                Err(()) => {
+                    walk.complete = false;
+                    self.query_work.set(walk);
+                    return None;
+                }
+            };
+            if let Some(tree) = tree {
+                let mut decode_limited = false;
+                let found = self.visit_names(tree, &mut HashSet::new(), 0, &mut walk, &mut |key, value| {
+                    // Charge before allocation/decoding, including repeated indirect keys.
+                    if !self.spend_name_decode(key) {
+                        decode_limited = true;
+                        return Some(value);
+                    }
+                    (text_string(key) == name).then_some(value)
+                });
+                walk.complete &= !decode_limited;
+                self.query_work.set(walk);
+                if !walk.complete {
+                    return None;
+                }
+                if found.is_some() {
+                    return found;
+                }
+            }
+            let catalog = self.doc.catalog().ok()?;
+            let Ok(legacy) = catalog.get(b"Dests") else { return None };
+            let Some(legacy) = self.dict(legacy) else {
+                walk.complete = false;
+                self.query_work.set(walk);
+                return None;
+            };
+            let mut found = None;
+            for (key, value) in legacy.iter() {
+                if !walk.spend() || !self.spend_name_decode(key) {
+                    walk.complete = false;
+                    break;
+                }
+                if text_string(key) == name {
+                    found = Some(value);
+                    break;
+                }
+            }
+            self.query_work.set(walk);
+            walk.complete.then_some(found).flatten()
+        })?;
+        self.dest(value, 0).filter(|(page, _)| *page < self.page_ids.len())
+    }
+
+    /// Shared first-occurrence traversal. Incomplete searches never authorize a fallback.
+    pub(super) fn visit_names(
         &self,
         node: &'a Dictionary,
         seen: &mut HashSet<*const Dictionary>,
         depth: u32,
-        out: &mut HashMap<Vec<u8>, &'a Object>,
-    ) {
-        if depth > 32 || !seen.insert(node as *const _) {
-            return;
+        walk: &mut NameWalk,
+        visit: &mut impl FnMut(&[u8], &'a Object) -> Option<&'a Object>,
+    ) -> Option<&'a Object> {
+        if depth > 32 {
+            walk.complete = false;
+            return None;
         }
-        if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) {
+        if !walk.spend() || !seen.insert(node as *const _) {
+            return None;
+        }
+        if let Ok(names) = node.get(b"Names") {
+            let Object::Array(pairs) = self.resolve(names) else {
+                walk.complete = false;
+                return None;
+            };
             for pair in pairs.chunks(2) {
-                if let [k, v] = pair {
-                    let key = match self.resolve(k) {
-                        Object::String(s, _) => s.clone(),
-                        Object::Name(n) => n.clone(),
-                        _ => continue,
-                    };
-                    out.entry(key).or_insert(v);
+                if !walk.spend() {
+                    return None;
+                }
+                let [key, value] = pair else {
+                    walk.complete = false;
+                    return None;
+                };
+                let key = match self.resolve(key) {
+                    Object::String(key, _) | Object::Name(key) => key,
+                    _ => {
+                        walk.complete = false;
+                        return None;
+                    }
+                };
+                if let Some(found) = visit(key, value) {
+                    return Some(found);
                 }
             }
         }
-        if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
-            for k in kids {
-                if let Some(d) = self.dict(k) {
-                    self.name_tree_raw(d, seen, depth + 1, out);
+        if let Ok(kids) = node.get(b"Kids") {
+            let Object::Array(kids) = self.resolve(kids) else {
+                walk.complete = false;
+                return None;
+            };
+            for kid in kids {
+                if !walk.spend() {
+                    return None;
+                }
+                let Some(dict) = self.dict(kid) else {
+                    walk.complete = false;
+                    return None;
+                };
+                if let Some(found) = self.visit_names(dict, seen, depth + 1, walk, visit) {
+                    return Some(found);
+                }
+                if !walk.complete {
+                    return None;
                 }
             }
         }
+        None
     }
 
     /// The entries of an embedded-files name tree. Each string key is spent from `text_budget`
@@ -547,4 +758,119 @@ mod open_dest_tests {
         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)));
     }
+
+    #[test]
+    fn on_demand_named_destinations_reuse_views_aliases_and_encoded_keys() {
+        for (catalog, extra, name, expected) in [
+            ("/Dests << /there [5 0 R /XYZ 100 500 2] >>", vec![], "there", (2, XYZ)),
+            ("/Dests << /alias /there /there [4 0 R /Fit] >>", vec![], "alias", (1, DestView::Fit)),
+            (
+                "/Names << /Dests 6 0 R >>",
+                vec!["<< /Kids [7 0 R] >>".into(), "<< /Names [(there) 8 0 R] >>".into(), "<< /D [5 0 R /FitH 400] >>".into()],
+                "there",
+                (2, DestView::FitH { top: Some(400.0) }),
+            ),
+            ("/Names << /Dests << /Names [<FEFF00630061006600E9> [4 0 R /Fit]] >> >>", vec![], "café", (1, DestView::Fit)),
+            ("/Names << /Dests << /Names [<636166E9> [4 0 R /Fit]] >> >>", vec![], "café", (1, DestView::Fit)),
+            ("/Names << /Dests << /Names [<EFBBBF636166C3A9> [4 0 R /Fit]] >> >>", vec![], "café", (1, DestView::Fit)),
+            ("/Names << /Dests << /Names [<636166C3A9> [3 0 R /Fit] <FEFF00630061006600E9> [4 0 R /Fit]] >> >>", vec![], "café", (0, DestView::Fit)),
+        ] {
+            assert_eq!(crate::named_destination(Arc::new(pdf(catalog, &extra)), None, name).unwrap(), Some(expected), "{catalog}");
+        }
+    }
+
+    #[test]
+    fn on_demand_named_destinations_refuse_missing_cyclic_and_out_of_range_targets() {
+        for catalog in [
+            "",
+            "/Dests << /x /x >>",
+            "/Dests << /x << /D /x >> >>",
+            "/Dests << /x [20 /Fit] >>",
+            "/Dests << /x [2 0 R /Fit] >>",
+            "/Names << /Dests 6 0 R >>",
+        ] {
+            assert_eq!(crate::named_destination(Arc::new(pdf(catalog, &["<< /Kids [6 0 R] >>".into()])), None, "x").unwrap(), None);
+        }
+        let depth = 40;
+        let mut nodes: Vec<String> = (0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 7 + i)).collect();
+        nodes.push("<< /Names [(x) [4 0 R /Fit]] >>".into());
+        assert!(!matches!(crate::named_destination(Arc::new(pdf("/Names << /Dests 6 0 R >>", &nodes)), None, "x"), Ok(Some(_))));
+        assert!(crate::named_destination(Arc::new(b"invalid PDF".to_vec()), None, "x").is_err());
+        assert!(crate::named_destination(Arc::new(pdf("", &[])), None, &"x".repeat(1025)).is_err());
+    }
+
+    #[test]
+    fn on_demand_name_tree_work_is_bounded_and_stops_at_the_first_match() {
+        let bytes = pdf("/Names << /Dests << /Names [(a) [3 0 R /Fit] (b) [4 0 R /Fit]] >> >>", &[]);
+        let doc = lopdf::Document::load_mem(&bytes).unwrap();
+        let inspector = super::Inspector::without_named_index(&doc);
+        assert!(inspector.named.is_none(), "one query does not index every name");
+        let tree = inspector.destination_tree().unwrap().unwrap();
+        let mut work = super::NameWalk { remaining: 1, complete: true };
+        let result = inspector.visit_names(tree, &mut std::collections::HashSet::new(), 0, &mut work, &mut |_, value| Some(value));
+        assert!(result.is_none());
+        assert_eq!(work.remaining, 0);
+        assert!(!work.complete);
+        let mut work = super::NameWalk { remaining: 2, complete: true };
+        let result = inspector.visit_names(tree, &mut std::collections::HashSet::new(), 0, &mut work, &mut |_, value| Some(value));
+        assert_eq!(inspector.dest(result.unwrap(), 0), Some((0, DestView::Fit)));
+    }
+    #[test]
+    fn on_demand_incomplete_trees_do_not_choose_a_conflicting_fallback() {
+        let depth = 40;
+        let leaf = 7 + depth;
+        let shadow = leaf + 1;
+        let mut nodes = vec![format!("<< /Kids [7 0 R {shadow} 0 R] >>")];
+        nodes.extend((0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 8 + i)));
+        nodes.push("<< /Names [<636166C3A9> [3 0 R /Fit]] >>".into());
+        nodes.push("<< /Names [<FEFF00630061006600E9> [4 0 R /Fit]] >>".into());
+        for legacy in ["", "/Dests << /caf#C3#A9 [5 0 R /Fit] >>"] {
+            let bytes = pdf(&format!("/Names << /Dests 6 0 R >> {legacy}"), &nodes);
+            assert!(
+                !matches!(crate::named_destination(Arc::new(bytes), None, "café"), Ok(Some(_))),
+                "an unread exact key must not select another destination"
+            );
+        }
+    }
+
+    #[test]
+    fn on_demand_repeated_indirect_keys_have_a_cumulative_decoding_bound() {
+        let repeated = "7 0 R [3 0 R /Fit] ".repeat(5000);
+        let nodes = [format!("<< /Names [{repeated} <FEFF00630061006600E9> [4 0 R /Fit]] >>"), format!("({})", "x".repeat(4098))];
+        let bytes = pdf("/Names << /Dests 6 0 R >>", &nodes);
+        assert!(bytes.len() < 128 * 1024, "small malformed source must not trigger unbounded text decoding");
+        assert!(!matches!(crate::named_destination(Arc::new(bytes), None, "café"), Ok(Some(_))));
+    }
+    #[test]
+    fn on_demand_batches_share_budgets_and_keep_earlier_successful_results() {
+        let repeated = "7 0 R [3 0 R /Fit] ".repeat(800);
+        let nodes = [format!("<< /Names [{repeated} <FEFF00630061006600E9> [4 0 R /Fit]] >>"), format!("({})", "x".repeat(4098))];
+        let results = crate::named_destinations(Arc::new(pdf("/Names << /Dests 6 0 R >>", &nodes)), None, &["café", "café"]).unwrap();
+        assert!(matches!(results[0], Ok(Some((1, DestView::Fit)))));
+        assert!(results[1].is_err(), "the next query cannot reset the shared decoding budget");
+        let invalid = "x".repeat(1025);
+        let results = crate::named_destinations(Arc::new(pdf("/Dests << /there [4 0 R /Fit] >>", &[])), None, &["there", &invalid, "there"]).unwrap();
+        assert!(matches!(results[0], Ok(Some((1, DestView::Fit)))));
+        assert!(results[1].is_err());
+        assert!(matches!(results[2], Ok(Some((1, DestView::Fit)))));
+    }
+    #[test]
+    fn on_demand_short_names_cannot_expand_into_oversized_raw_aliases() {
+        let alias = format!("{}z", "x".repeat(4098));
+        let near = format!("{}y", "x".repeat(4098));
+        let nodes = [format!("<< /Names [{} 7 0 R [4 0 R /Fit]] >>", "8 0 R [3 0 R /Fit] ".repeat(64)), format!("({alias})"), format!("({near})")];
+        let bytes = pdf("/Names << /Dests 6 0 R >> /Dests << /external 7 0 R >>", &nodes);
+        assert!(bytes.len() < 16 * 1024);
+        assert!(!matches!(crate::named_destination(Arc::new(bytes), None, "external"), Ok(Some(_))));
+    }
+
+    #[test]
+    fn on_demand_raw_key_comparisons_share_the_cumulative_byte_budget() {
+        let key = format!("{}z", "q".repeat(999));
+        let near = format!("{}y", "q".repeat(999));
+        let nodes = [format!("<< /Names [{} ({key}) [4 0 R /Fit]] >>", "7 0 R [3 0 R /Fit] ".repeat(20_000)), format!("({near})")];
+        let bytes = pdf("/Names << /Dests 6 0 R >>", &nodes);
+        assert!(bytes.len() < 1024 * 1024);
+        assert!(!matches!(crate::named_destination(Arc::new(bytes), None, &key), Ok(Some(_))));
+    }
 }
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
@@ -17,8 +17,9 @@ pub mod text;
 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,
+    Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget,
+    NamedDestinationResult, OutlineItem, PageInfo, PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect,
+    link_replies, named_destination, named_destinations, pretty_date, thread_tops,
 };
 pub use pixels::Pixels;
 pub use raster::{
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -446,8 +446,21 @@ impl eframe::App for PdfCraftApp {
                     ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
                     ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
                 }
+                #[cfg(not(target_arch = "wasm32"))]
+                OsEvent::OpenArguments(arguments) => {
+                    for argument in arguments {
+                        match NativeOpenTarget::from_arg(&argument) {
+                            Ok(target) => {
+                                self.open_target(target);
+                            }
+                            Err(error) => self.notify(error),
+                        }
+                    }
+                    ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
+                    ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
+                }
                 #[cfg(target_arch = "wasm32")]
-                OsEvent::Open(_) => {}
+                OsEvent::Open(_) | OsEvent::OpenArguments(_) => {}
                 // Like closing the window: `guard_quit` asks about unsaved changes.
                 OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
             }
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
@@ -85,7 +85,7 @@ impl PdfCraftApp {
             }
             return r;
         }
-        self.try_open(name, path, std::sync::Arc::new(bytes), None, activate)
+        self.try_open(name, path, std::sync::Arc::new(bytes), None, activate).map(|_| ())
     }
 
     #[cfg(not(target_arch = "wasm32"))]
@@ -179,9 +179,7 @@ impl PdfCraftApp {
                         if pending.path.is_some() {
                             self.watch.known_as(id, pending.stamp);
                         }
-                        if let Some(destination) = pending.after_open {
-                            self.apply_remote_destination(destination);
-                        }
+                        self.apply_open_destinations(id, pending.after_open);
                         if let Some(meta) = &pending.recovered {
                             self.finish_recovery(meta);
                         }
@@ -219,7 +217,7 @@ impl PdfCraftApp {
         bytes: std::sync::Arc<Vec<u8>>,
         password: Option<&str>,
         activate: bool,
-    ) -> Result<(), String> {
+    ) -> Result<Option<DocId>, String> {
         use pdfcraft_render::OpenError;
         let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) {
             Ok(prepared) => prepared,
@@ -238,13 +236,13 @@ impl PdfCraftApp {
                     activate,
                     recovered: None,
                     then: None,
-                    after_open: None,
+                    after_open: Vec::new(),
                 });
-                return Ok(());
+                return Ok(None);
             }
             Err(e) => return Err(e.to_string()),
         };
-        self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ())
+        self.finish_open(name, path, bytes.len(), prepared, activate).map(Some)
     }
 
     fn finish_open(
@@ -444,20 +442,12 @@ impl PdfCraftApp {
         match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) {
             Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]),
             // A recovered encrypted document is open once its prompt is gone.
-            Ok(()) if self.password_prompt.is_none() => {
+            Ok(Some(id)) => {
                 #[cfg(not(target_arch = "wasm32"))]
-                if let Some(path) = path_for_watch.as_deref()
-                    && let Some(id) = self
-                        .views
-                        .iter()
-                        .rev()
-                        .find_map(|view| self.session.get(view.id).and_then(|doc| (doc.path.as_deref() == Some(path)).then_some(view.id)))
-                {
+                if path_for_watch.is_some() {
                     self.watch.known_as(id, stamp_for_watch);
                 }
-                if let Some(destination) = after_open {
-                    self.apply_remote_destination(destination);
-                }
+                self.apply_open_destinations(id, after_open);
                 if let Some(meta) = &p.recovered {
                     self.finish_recovery(meta);
                 }
@@ -467,7 +457,7 @@ impl PdfCraftApp {
             }
             // Wrong password: the new prompt is for the same bytes, so it keeps the snapshot
             // and the tool waiting for it.
-            Ok(()) => {
+            Ok(None) => {
                 if let Some(prompt) = self.password_prompt.as_mut() {
                     prompt.recovered = p.recovered;
                     prompt.then = p.then;
@@ -533,17 +523,47 @@ 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 {
+        self.open_path_with_destinations(path, then, Vec::new())
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    pub(crate) fn open_path_with_destinations(
+        &mut self,
+        path: &str,
+        then: Option<&'static str>,
+        destinations: Vec<pdfcraft_render::RemoteDestination>,
+    ) -> OpenOutcome {
         // 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(id) = self.views.get(i).map(|view| view.id) {
+                self.apply_open_destinations(id, destinations);
+            }
             if let Some(command) = then {
                 self.execute(command);
             }
             return OpenOutcome::Focused;
         }
-        if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
+        if let Some(pending) = self.pending_opens.iter_mut().find(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
+            if !destinations.is_empty() {
+                pending.after_open = destinations;
+            }
+            pending.activate = true;
+            if then.is_some() {
+                pending.then = then;
+            }
+            return OpenOutcome::Pending;
+        }
+        if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
+            if !destinations.is_empty() {
+                prompt.after_open = destinations;
+            }
+            prompt.activate = true;
+            if then.is_some() {
+                prompt.then = then;
+            }
             return OpenOutcome::Pending;
         }
         let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
@@ -552,7 +572,7 @@ impl PdfCraftApp {
         // Before the first frame there is no interaction to keep responsive; this also preserves
         // deterministic inline startup and test flows. Once live, reading and preparation use
         // the bounded worker and commit on the UI thread.
-        if self.ctx.is_none() || self.run_inline {
+        if (self.ctx.is_none() || self.run_inline) && self.password_prompt.is_none() && self.pending_opens.is_empty() {
             match std::fs::read(path) {
                 Ok(bytes) => {
                     let before = self.views.len();
@@ -563,9 +583,11 @@ impl PdfCraftApp {
                     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.
                         prompt.then = then;
+                        prompt.after_open = destinations;
                     } else if self.views.len() > before {
-                        if let Some(doc) = self.views.last().map(|view| view.id) {
+                        if let Some(doc) = self.views.get(before).map(|view| view.id) {
                             self.watch.known_as(doc, stamp);
+                            self.apply_open_destinations(doc, destinations);
                         }
                         if let Some(command) = then {
                             self.execute(command);
@@ -591,7 +613,7 @@ impl PdfCraftApp {
                 input: open_worker::Input::Path(std::path::PathBuf::from(path)),
                 password: None,
                 activate: true,
-                after_open: None,
+                after_open: destinations,
                 recovered: None,
                 then,
             }) {
diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs
--- a/crates/ui-egui/src/app/input.rs
+++ b/crates/ui-egui/src/app/input.rs
@@ -28,7 +28,7 @@ impl PdfCraftApp {
                         input: open_worker::Input::Bytes(std::sync::Arc::new(bytes)),
                         password: None,
                         activate: true,
-                        after_open: None,
+                        after_open: Vec::new(),
                         recovered: None,
                         then: None,
                     });
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
@@ -46,7 +46,7 @@ impl PdfCraftApp {
             input: open_worker::Input::RemotePath(std::path::PathBuf::from(path)),
             password: None,
             activate: true,
-            after_open: Some(destination),
+            after_open: vec![destination],
             recovered: None,
             then: None,
         });
@@ -65,20 +65,43 @@ impl PdfCraftApp {
         self.pending_remote_link = None;
     }
 
-    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 };
-        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);
-                None
+    pub(super) fn apply_open_destinations(&mut self, id: DocId, destinations: Vec<pdfcraft_render::RemoteDestination>) {
+        let Some(index) = self.views.iter().position(|view| view.id == id) else { return };
+        let Some(doc) = self.session.get(id) else { return };
+        let names: Vec<_> = destinations
+            .iter()
+            .filter_map(|destination| match destination {
+                pdfcraft_render::RemoteDestination::Named(name) => Some(name.as_str()),
+                _ => None,
+            })
+            .collect();
+        let mut named = match pdfcraft_render::named_destinations(doc.bytes.clone(), doc.password.as_deref(), &names) {
+            Ok(results) => results,
+            Err(error) => {
+                self.notify_fmt("The PDF opened, but its destinations could not be read: {reason}", &[("reason", &error.to_string())]);
+                Vec::new()
+            }
+        }
+        .into_iter();
+        for destination in destinations {
+            let Some(doc) = self.session.get(id) else { return };
+            let resolved = match destination {
+                pdfcraft_render::RemoteDestination::Default => continue,
+                pdfcraft_render::RemoteDestination::Page { page, view } => Ok((page < doc.info.pages.len()).then_some((page, view))),
+                pdfcraft_render::RemoteDestination::Named(_) => {
+                    let Some(result) = named.next() else { continue };
+                    result
+                }
+            };
+            match resolved {
+                Ok(Some((page, view))) => {
+                    if let Some(target) = self.views.get_mut(index) {
+                        target.go_to_dest(page, view, &doc.info);
+                    }
+                }
+                Ok(None) => self.notify_tr("The PDF opened, but a requested destination could not be applied."),
+                Err(error) => self.notify_fmt("The PDF opened, but its destination could not be read: {reason}", &[("reason", &error.to_string())]),
             }
-            pdfcraft_render::RemoteDestination::Page { .. } => Some("The linked PDF opened, but its page destination was out of range."),
-        };
-        if let Some(warning) = warning {
-            self.notify_tr(warning);
         }
     }
 
diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs
--- a/crates/ui-egui/src/hooks.rs
+++ b/crates/ui-egui/src/hooks.rs
@@ -11,6 +11,8 @@ pub type FailedInbox = std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>;
 pub enum OsEvent {
     /// Open these files.
     Open(Vec<String>),
+    /// Native launch arguments, which may include file URI escaping and PDF navigation.
+    OpenArguments(Vec<String>),
     /// Quit (the Dock's Quit, logging out), asking about unsaved changes first.
     Quit,
 }
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
@@ -62,6 +62,10 @@ use theme::{ThemeKind, ThemePreference};
 
 mod app;
 mod hooks;
+#[cfg(not(target_arch = "wasm32"))]
+mod native_open;
+#[cfg(not(target_arch = "wasm32"))]
+pub use native_open::NativeOpenTarget;
 mod persist;
 mod platform;
 mod state;
@@ -224,7 +228,7 @@ struct PendingOpen {
     path: Option<String>,
     stamp: Option<file_watch::Stamp>,
     activate: bool,
-    after_open: Option<pdfcraft_render::RemoteDestination>,
+    after_open: Vec<pdfcraft_render::RemoteDestination>,
     recovered: Option<RecoveryMeta>,
     then: Option<&'static str>,
 }
diff --git a/crates/ui-egui/src/native_open.rs b/crates/ui-egui/src/native_open.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/native_open.rs
@@ -0,0 +1,395 @@
+//! Native launch arguments can carry navigation; filesystem-path APIs stay literal.
+
+use pdfcraft_render::{DestView, RemoteDestination};
+
+const MAX_ARGUMENT_BYTES: usize = 1 << 20;
+const MAX_PATH_BYTES: usize = 128 << 10;
+const MAX_FRAGMENT_BYTES: usize = 64 << 10;
+const MAX_DESTINATIONS: usize = 64;
+// Every semantic byte may expand to three URI bytes, plus bounded parameter headers.
+const MAX_ENCODED_FRAGMENT_BYTES: usize = 3 * MAX_FRAGMENT_BYTES + 32 * MAX_DESTINATIONS;
+const MAX_NAME_BYTES: usize = 1024;
+
+/// A local file and its bounded, ordered navigation requests from a native launcher.
+#[derive(Clone, Debug)]
+pub struct NativeOpenTarget {
+    pub(crate) path: String,
+    pub(crate) destinations: Vec<RemoteDestination>,
+    has_parameters: bool,
+}
+
+impl NativeOpenTarget {
+    /// Parse one launch argument, keeping URI escaping distinct from literal path characters.
+    /// Existing plain filenames win over fragment syntax. Only local file URIs are decoded.
+    pub fn from_arg(argument: &str) -> Result<Self, String> {
+        if argument.len() > MAX_ARGUMENT_BYTES {
+            return Err("The file launch argument is too long.".into());
+        }
+        if argument.contains('\0') {
+            return Err("The file launch argument contains a NUL character.".into());
+        }
+        let (path, fragment) = if let Some(rest) =
+            argument.get(..7).filter(|prefix| prefix.eq_ignore_ascii_case("file://")).and_then(|_| argument.get(7..))
+        {
+            let (path, fragment) = rest.split_once('#').map_or((rest, None), |(p, f)| (p, Some(f)));
+            let path = if path.starts_with('/') {
+                path
+            } else {
+                let (host, _) = path.split_once('/').ok_or("The file URI has no local path.")?;
+                if !host.eq_ignore_ascii_case("localhost") {
+                    return Err("Only local file URIs are supported.".into());
+                }
+                // The slash was the ASCII delimiter of split_once, hence a character boundary.
+                path.get(host.len()..).ok_or("The file URI has no local path.")?
+            };
+            let decoded = percent_decode(path)?;
+            let decoded = if cfg!(windows)
+                && decoded.starts_with('/')
+                && (decoded.as_bytes().get(2) == Some(&b':') || decoded.get(1..).is_some_and(|path| path.starts_with("\\\\")))
+            {
+                decoded.get(1..).ok_or("The file URI has no local path.")?.to_string()
+            } else {
+                decoded
+            };
+            (decoded, fragment)
+        } else if std::path::Path::new(argument).is_file() {
+            (argument.to_string(), None)
+        } else {
+            // A hash may belong to a directory or filename. Only a real file prefix can
+            // introduce launch navigation, and at most 64 prefixes are probed.
+            let boundaries: Vec<_> = argument.match_indices('#').take(MAX_DESTINATIONS + 1).map(|(at, _)| at).collect();
+            if boundaries.len() > MAX_DESTINATIONS {
+                return Err("The file launch argument has too many fragment boundaries.".into());
+            }
+            let candidate = |at: usize| Some((argument.get(..at)?, argument.get(at.checked_add(1)?..)?)).filter(|(_, fragment)| recognized(fragment));
+            let existing = boundaries.iter().rev().filter_map(|&at| candidate(at)).find(|(path, _)| std::path::Path::new(path).is_file());
+            // Missing files still keep their fragment out of filesystem normalization.
+            let syntactic = || {
+                boundaries
+                    .iter()
+                    .filter_map(|&at| candidate(at))
+                    .find(|(path, _)| std::path::Path::new(path).extension().is_some_and(|extension| extension.eq_ignore_ascii_case("pdf")))
+            };
+            existing.or_else(syntactic).map_or_else(|| (argument.to_string(), None), |(path, fragment)| (path.to_string(), Some(fragment)))
+        };
+        let has_parameters = fragment.is_some();
+        let mut destinations = Vec::new();
+        if let Some(fragment) = fragment {
+            if fragment.len() > MAX_ENCODED_FRAGMENT_BYTES {
+                return Err("The PDF launch parameters are too long.".into());
+            }
+            let mut value_bytes = 0usize;
+            for (index, token) in fragment.split(['&', '#']).enumerate() {
+                if index >= MAX_DESTINATIONS {
+                    return Err("There are too many PDF launch parameters.".into());
+                }
+                let Some((key, value)) = token.split_once('=') else { continue };
+                let value = percent_decode(value)?;
+                value_bytes = value_bytes
+                    .checked_add(value.len())
+                    .filter(|size| *size <= MAX_FRAGMENT_BYTES)
+                    .ok_or("The decoded PDF launch parameters are too long.")?;
+                match key {
+                    "page" => {
+                        let page = value
+                            .parse::<usize>()
+                            .ok()
+                            .and_then(|n| n.checked_sub(1))
+                            .ok_or("The PDF launch page must be a positive physical page number.")?;
+                        destinations.push(RemoteDestination::Page { page, view: DestView::Top });
+                    }
+                    "nameddest" => {
+                        let name = value;
+                        if name.is_empty() || name.len() > MAX_NAME_BYTES || name.contains('\0') {
+                            return Err("The PDF launch destination name is empty or too long.".into());
+                        }
+                        destinations.push(RemoteDestination::Named(name));
+                    }
+                    // Unsupported view parameters never become filesystem paths or actions.
+                    _ => {}
+                }
+            }
+        }
+        // Normalize only the filesystem path, never slashes or dot segments in a name.
+        let absolute = std::path::absolute(&path).map_err(|error| error.to_string())?;
+        let path = absolute.to_str().ok_or("The absolute launch path is not valid UTF-8.")?.to_string();
+        if path.len() > MAX_PATH_BYTES {
+            return Err("The absolute launch path is too long.".into());
+        }
+        Ok(Self { path, destinations, has_parameters })
+    }
+
+    /// Whether the argument carried launch parameters, including unsupported view parameters.
+    pub fn has_launch_parameters(&self) -> bool {
+        self.has_parameters
+    }
+
+    /// Keep parameters out of platform path normalization when forwarding to another process.
+    /// Every target uses an escaped file URI so literal hashes stay unambiguous even if the
+    /// file disappears before the receiving process opens it.
+    pub fn absolute_argument(&self) -> Result<String, String> {
+        let path = &self.path;
+        let mut encoded = String::from("file://");
+        if !path.starts_with('/') {
+            encoded.push('/');
+        }
+        encode_into(path, true, &mut encoded)?;
+        for (index, destination) in self.destinations.iter().enumerate() {
+            encoded.push(if index == 0 { '#' } else { '&' });
+            match destination {
+                RemoteDestination::Page { page, .. } => {
+                    let page = page.checked_add(1).ok_or("The PDF launch page is too large.")?;
+                    use std::fmt::Write;
+                    write!(&mut encoded, "page={page}").map_err(|e| e.to_string())?;
+                }
+                RemoteDestination::Named(name) => {
+                    encoded.push_str("nameddest=");
+                    encode_into(name, false, &mut encoded)?;
+                }
+                RemoteDestination::Default => {}
+            }
+        }
+        if encoded.len() > MAX_ARGUMENT_BYTES {
+            return Err("The encoded file launch argument is too long.".into());
+        }
+        Ok(encoded)
+    }
+
+    /// The ordered navigation requests, bounded by the launch parser.
+    pub fn destinations(&self) -> &[RemoteDestination] {
+        &self.destinations
+    }
+
+    /// The literal filesystem path, without launch navigation.
+    pub fn path(&self) -> &str {
+        &self.path
+    }
+}
+
+fn recognized(fragment: &str) -> bool {
+    let mut navigation = false;
+    for token in fragment.split(['&', '#']).take(MAX_DESTINATIONS) {
+        if token.is_empty() {
+            continue;
+        }
+        let Some((key, _)) = token.split_once('=') else { return false };
+        navigation |= matches!(key, "page" | "nameddest");
+    }
+    navigation
+}
+
+fn encode_into(value: &str, path: bool, output: &mut String) -> Result<(), String> {
+    use std::fmt::Write;
+    for byte in value.bytes() {
+        let literal = byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) || (path && b"/:".contains(&byte));
+        let required = if literal { 1 } else { 3 };
+        if output.len().checked_add(required).is_none_or(|size| size > MAX_ARGUMENT_BYTES) {
+            return Err("The encoded file launch argument is too long.".into());
+        }
+        if literal {
+            output.push(char::from(byte));
+        } else {
+            write!(output, "%{byte:02X}").map_err(|e| e.to_string())?;
+        }
+    }
+    Ok(())
+}
+
+/// Decode once, after separating delimiters. Plus is a literal PDF name character.
+fn percent_decode(value: &str) -> Result<String, String> {
+    let mut out = Vec::with_capacity(value.len());
+    let mut bytes = value.bytes();
+    while let Some(byte) = bytes.next() {
+        if byte == b'%' {
+            let hex: Vec<_> = bytes.clone().take(2).collect();
+            if let Some(decoded) = std::str::from_utf8(&hex).ok().filter(|s| s.len() == 2).and_then(|s| u8::from_str_radix(s, 16).ok()) {
+                out.push(decoded);
+                bytes.nth(1);
+                continue;
+            }
+        }
+        out.push(byte);
+    }
+    let decoded = String::from_utf8(out).map_err(|_| "The file launch argument has invalid UTF-8 escapes.".to_string())?;
+    if decoded.contains('\0') {
+        return Err("The file launch argument contains a NUL character.".into());
+    }
+    Ok(decoded)
+}
+
+impl crate::PdfCraftApp {
+    /// Open a prepared launch target through the ordinary document lifecycle.
+    pub fn open_target(&mut self, target: NativeOpenTarget) -> crate::state::OpenOutcome {
+        self.open_path_with_destinations(&target.path, None, target.destinations)
+    }
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod tests {
+    use std::path::PathBuf;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    use super::*;
+
+    struct Directory(PathBuf);
+    impl Directory {
+        fn new() -> Self {
+            static NEXT: AtomicUsize = AtomicUsize::new(0);
+            for _ in 0..100 {
+                let path = std::env::temp_dir().join(format!("pdfcraft-launch-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
+                if std::fs::create_dir(&path).is_ok() {
+                    return Self(path);
+                }
+            }
+            panic!("could not create an isolated fixture directory");
+        }
+        fn file(&self, name: &str) -> PathBuf {
+            let path = self.0.join(name);
+            if let Some(parent) = path.parent() {
+                std::fs::create_dir_all(parent).unwrap();
+            }
+            std::fs::write(&path, b"original parser fixture").unwrap();
+            path
+        }
+    }
+    impl Drop for Directory {
+        fn drop(&mut self) {
+            let _ = std::fs::remove_dir_all(&self.0);
+        }
+    }
+    fn uri(path: &std::path::Path) -> String {
+        let path = path.to_string_lossy().replace('\\', "/").replace('%', "%25").replace('#', "%23").replace(' ', "%20");
+        format!("file://{}{path}", if path.starts_with('/') { "" } else { "/" })
+    }
+
+    #[test]
+    fn literal_hash_and_percent_filenames_stay_distinct_from_uri_fragments() {
+        let dir = Directory::new();
+        for name in ["literal#page=2.pdf", "literal%20.pdf", "draft#part/manual with spaces.pdf"] {
+            let file = dir.file(name);
+            let plain = NativeOpenTarget::from_arg(&file.to_string_lossy()).unwrap();
+            assert_eq!(std::path::Path::new(plain.path()), file.as_path());
+            assert!(plain.destinations.is_empty());
+            let encoded = NativeOpenTarget::from_arg(&uri(&file)).unwrap();
+            assert_eq!(std::path::Path::new(encoded.path()), file.as_path());
+            assert!(encoded.destinations.is_empty(), "encoded hashes are filename characters");
+            let target = NativeOpenTarget::from_arg(&format!("{}#page=2", file.display())).unwrap();
+            assert_eq!(std::path::Path::new(target.path()), file.as_path());
+            assert_eq!(target.destinations, [RemoteDestination::Page { page: 1, view: DestView::Top }]);
+        }
+    }
+
+    #[test]
+    fn fragment_delimiters_are_split_before_decoding_and_order_is_retained() {
+        let dir = Directory::new();
+        let file = dir.file("manual with spaces.pdf");
+        let suffix = "#page=2&nameddest=chapter%26two%23x+z#page=3&zoom=100";
+        for argument in [format!("{}{suffix}", file.display()), format!("{}{suffix}", uri(&file))] {
+            let target = NativeOpenTarget::from_arg(&argument).unwrap();
+            assert_eq!(std::path::Path::new(target.path()), file.as_path());
+            assert_eq!(
+                target.destinations,
+                [
+                    RemoteDestination::Page { page: 1, view: DestView::Top },
+                    RemoteDestination::Named("chapter&two#x+z".into()),
+                    RemoteDestination::Page { page: 2, view: DestView::Top },
+                ]
+            );
+        }
+        let target = NativeOpenTarget::from_arg(&format!("{}#nameddest=caf%C3%A9%2520", uri(&file))).unwrap();
+        assert_eq!(target.destinations, [RemoteDestination::Named("café%20".into())], "decode exactly once");
+        let localhost = uri(&file).replacen("file:///", "file://LOCALHOST/", 1);
+        assert_eq!(std::path::Path::new(NativeOpenTarget::from_arg(&localhost).unwrap().path()), file.as_path());
+    }
+
+    #[test]
+    fn invalid_and_oversized_navigation_is_refused_without_remote_file_access() {
+        let dir = Directory::new();
+        let file = dir.file("manual.pdf");
+        for fragment in
+            ["page=0", "page=-1", "page=1.5", "page=NaN", "page=99999999999999999999999999999", "nameddest=", "nameddest=%FF", "nameddest=%00"]
+        {
+            assert!(NativeOpenTarget::from_arg(&format!("{}#{fragment}", uri(&file))).is_err(), "{fragment}");
+        }
+        assert!(NativeOpenTarget::from_arg("file://example.invalid/manual.pdf#page=2").is_err());
+        assert!(NativeOpenTarget::from_arg(&format!("{}#nameddest={}", uri(&file), "x".repeat(MAX_NAME_BYTES + 1))).is_err());
+        assert!(NativeOpenTarget::from_arg(&format!("{}#{}", uri(&file), vec!["page=1"; MAX_DESTINATIONS + 1].join("&"))).is_err());
+        assert!(NativeOpenTarget::from_arg(&"x".repeat(MAX_ARGUMENT_BYTES + 1)).is_err());
+        assert!(NativeOpenTarget::from_arg("bad\0path.pdf").is_err());
+        assert_eq!(NativeOpenTarget::from_arg("file:///tmp/%zz.pdf").unwrap().path(), "/tmp/%zz.pdf");
+    }
+    #[test]
+    fn native_hash_filenames_choose_the_longest_existing_file_before_navigation() {
+        let dir = Directory::new();
+        let short = dir.file("manual.pdf");
+        for name in ["manual.pdf#draft.pdf", "manual.pdf#nameddest=literal.pdf"] {
+            let long = dir.file(name);
+            let target = NativeOpenTarget::from_arg(&format!("{}#page=2", long.display())).unwrap();
+            assert_eq!(std::path::Path::new(target.path()), long.as_path());
+            assert_ne!(std::path::Path::new(target.path()), short.as_path());
+        }
+    }
+    #[test]
+    fn native_navigation_absolute_round_trips_preserve_filename_and_value_escaping() {
+        let dir = Directory::new();
+        let file = dir.file("manual#draft%20.pdf");
+        let original = NativeOpenTarget::from_arg(&format!("{}#nameddest=section//part/../caf%C3%A9+%2520", file.display())).unwrap();
+        let received = NativeOpenTarget::from_arg(&original.absolute_argument().unwrap()).unwrap();
+        assert_eq!(std::path::Path::new(received.path()), file.as_path());
+        assert_eq!(received.destinations(), original.destinations());
+    }
+
+    #[test]
+    fn native_canonical_literals_stay_literal_after_the_file_disappears() {
+        let dir = Directory::new();
+        dir.file("manual.pdf");
+        let literal = dir.file("manual.pdf#nameddest=chapterTwo.pdf");
+        let target = NativeOpenTarget::from_arg(literal.to_str().unwrap()).unwrap();
+        std::fs::remove_file(&literal).unwrap();
+        let received = NativeOpenTarget::from_arg(&target.absolute_argument().unwrap()).unwrap();
+        assert_eq!(received.path(), literal.to_str().unwrap());
+        assert!(received.destinations().is_empty());
+    }
+
+    #[test]
+    fn native_semantic_and_wire_limits_accept_the_maximum_canonical_target() {
+        let names = vec![format!("nameddest={}", "+".repeat(1024)); MAX_DESTINATIONS].join("&");
+        let target = NativeOpenTarget::from_arg(&format!("manual.pdf#{names}")).unwrap();
+        let received = NativeOpenTarget::from_arg(&target.absolute_argument().unwrap()).unwrap();
+        assert_eq!(received.destinations(), target.destinations());
+        assert!(std::path::Path::new(received.path()).is_absolute());
+        let oversized = format!("file:///manual.pdf#ignored={}", "x".repeat(MAX_FRAGMENT_BYTES + 1));
+        assert!(NativeOpenTarget::from_arg(&oversized).is_err());
+        let oversized_wire = format!("file:///manual.pdf#ignored={}", "%".repeat(MAX_ENCODED_FRAGMENT_BYTES));
+        assert!(NativeOpenTarget::from_arg(&oversized_wire).is_err());
+        assert!(NativeOpenTarget::from_arg(&format!("file:///{}.pdf", "x".repeat(MAX_PATH_BYTES))).is_err());
+    }
+
+    #[cfg(windows)]
+    #[test]
+    fn native_navigation_absolute_round_trips_windows_drive_unc_and_verbatim_paths() {
+        for path in [r"C:\docs\manual.pdf", r"\\server\share\manual.pdf", r"\\?\C:\docs\manual.pdf", r"\\?\UNC\server\share\manual.pdf"] {
+            let original = NativeOpenTarget {
+                path: path.to_string(),
+                destinations: vec![RemoteDestination::Named("section/../target+%20".into())],
+                has_parameters: true,
+            };
+            let received = NativeOpenTarget::from_arg(&original.absolute_argument().unwrap()).unwrap();
+            assert_eq!(std::path::Path::new(received.path()), std::path::Path::new(path));
+            assert_eq!(received.destinations(), original.destinations());
+        }
+    }
+    #[test]
+    fn native_canonical_navigation_keeps_accepted_semantic_fragment_limits() {
+        let dir = Directory::new();
+        let file = dir.file("manual.pdf");
+        let parameters = vec![format!("nameddest={}", "+".repeat(1000)); 32].join("&");
+        assert!(parameters.len() < 64 * 1024);
+        let original = NativeOpenTarget::from_arg(&format!("{}#{parameters}", file.display())).unwrap();
+        let encoded = original.absolute_argument().unwrap();
+        let received = NativeOpenTarget::from_arg(&encoded).expect("canonical encoding must remain accepted by the receiver");
+        assert_eq!(received.path(), original.path());
+        assert_eq!(received.destinations(), original.destinations());
+    }
+}
diff --git a/crates/ui-egui/src/persist/recovery.rs b/crates/ui-egui/src/persist/recovery.rs
--- a/crates/ui-egui/src/persist/recovery.rs
+++ b/crates/ui-egui/src/persist/recovery.rs
@@ -355,7 +355,7 @@ impl PdfCraftApp {
                     input: crate::open_worker::Input::Recovery(store.clone(), key.clone()),
                     password: None,
                     activate: true,
-                    after_open: None,
+                    after_open: Vec::new(),
                     recovered: Some(meta.clone()),
                     then: None,
                 });
diff --git a/crates/ui-egui/src/platform/open_worker/tests.rs b/crates/ui-egui/src/platform/open_worker/tests.rs
--- a/crates/ui-egui/src/platform/open_worker/tests.rs
+++ b/crates/ui-egui/src/platform/open_worker/tests.rs
@@ -702,3 +702,124 @@ fn a_new_file_from_the_os_brings_the_window_forward_before_it_is_prepared() {
     assert_eq!(doc.info.pages.len(), 2);
     let _ = std::fs::remove_file(path);
 }
+
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+fn native_destination_fixture() -> Vec<u8> {
+    use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full};
+    let mut source = Document::open(Arc::new(fixture(2))).expect("synthetic manual");
+    let destination = pdfcraft_model::pages(&source)[1].obj;
+    let mut names = Dict::new();
+    names.set(b"chapterTwo".to_vec(), Object::Array(vec![Object::Ref(destination), Object::Name(b"Fit".to_vec())]));
+    source.update_dict(source.root().expect("catalog"), |d| d.set(b"Dests".to_vec(), Object::Dict(names))).expect("named destination");
+    write_full(&source, &SaveOptions::default()).expect("write manual")
+}
+
+#[test]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+fn native_launch_destination_opens_the_file_and_overrides_its_current_page() {
+    let bytes = native_destination_fixture();
+    let file = tool_open_file("launch destination", "pdf", &bytes);
+    for fragment in ["nameddest=chapterTwo", "page=2"] {
+        let mut app = PdfCraftApp::new();
+        app.open_path(&file.0.to_string_lossy());
+        assert_eq!(app.views.len(), 1, "the bare manual opens");
+        assert_eq!(app.views[0].current, 0);
+        app.open_bytes("unrelated.pdf", None, fixture(3)).expect("another tab");
+        let argument = format!("{}#{fragment}", file.0.display());
+        let outcome = app.open_target(crate::NativeOpenTarget::from_arg(&argument).expect("launch target"));
+        assert!(matches!(outcome, crate::state::OpenOutcome::Focused), "the target focuses the existing manual");
+        assert_eq!(app.views.len(), 2);
+        assert_eq!(app.active, Some(0));
+        assert_eq!(app.views[1].current, 0, "the last tab is not the destination recipient");
+        assert_eq!(app.views[0].current, 1, "the requested physical destination is shown");
+        assert!(!app.session.get(app.views[0].id).expect("manual").dirty);
+    }
+}
+
+#[test]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+fn native_launch_destination_survives_wrong_password_and_duplicate_requests() {
+    let mut session = pdfcraft_engine::Session::new();
+    let id = session.open("manual.pdf", None, Arc::new(native_destination_fixture()), None).expect("manual");
+    session
+        .apply(id, pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { open_password: Some("tool-password".into()), ..Default::default() }))
+        .expect("encrypt");
+    let file = tool_open_file("native-password", "pdf", &session.save_bytes(id).expect("encrypted manual"));
+    let mut app = PdfCraftApp::new();
+    app.open_target(crate::NativeOpenTarget::from_arg(&format!("{}#page=1", file.0.display())).unwrap());
+    assert!(app.password_prompt.is_some());
+    app.open_target(crate::NativeOpenTarget::from_arg(&format!("{}#nameddest=chapterTwo", file.0.display())).unwrap());
+    app.open_path(&file.0.to_string_lossy());
+    app.submit_password(Some("wrong".into()));
+    assert!(app.password_prompt.as_ref().unwrap().error.is_some());
+    app.submit_password(Some("tool-password".into()));
+    assert!(app.password_prompt.is_none());
+    assert_eq!(app.views.len(), 1);
+    assert_eq!(app.views[0].current, 1);
+    let doc = app.session.get(app.views[0].id).unwrap();
+    assert_eq!(doc.path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
+    assert!(!doc.dirty);
+}
+
+#[test]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+fn native_launch_destination_updates_its_pending_file_and_preserves_other_tabs() {
+    let (mut h, _) = harness();
+    let held = HeldToolOpen::install(&mut h);
+    let file = tool_open_file("native-pending", "pdf", &native_destination_fixture());
+    h.state_mut().views[0].go_to_page(2);
+    h.state_mut().open_target(crate::NativeOpenTarget::from_arg(&format!("{}#page=1", file.0.display())).unwrap());
+    held.wait_started();
+    let repeated = h.state_mut().open_target(crate::NativeOpenTarget::from_arg(&format!("{}#nameddest=chapterTwo", file.0.display())).unwrap());
+    h.state_mut().open_path(&file.0.to_string_lossy());
+    held.complete(&mut h);
+    assert!(matches!(repeated, crate::state::OpenOutcome::Pending));
+    assert_eq!(h.state().views.len(), 2);
+    assert_eq!(h.state().views[0].current, 2);
+    assert_eq!(h.state().views[1].current, 1);
+}
+
+#[test]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+fn native_launch_destination_leaves_prior_navigation_when_a_later_name_is_missing() {
+    let file = tool_open_file("native-sequence", "pdf", &native_destination_fixture());
+    let mut app = PdfCraftApp::new();
+    app.open_target(crate::NativeOpenTarget::from_arg(&format!("{}#page=2&nameddest=unknown", file.0.display())).unwrap());
+    assert_eq!(app.views[0].current, 1);
+    // Literal-path consumers do not acquire launcher semantics.
+    let mut literal = PdfCraftApp::new();
+    literal.open_path(&format!("{}#page=2", file.0.display()));
+    assert!(literal.views.is_empty());
+}
+
+#[test]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+fn native_launch_destination_from_forwarded_arguments_survives_preparation() {
+    let (mut h, _) = harness();
+    let held = HeldToolOpen::install(&mut h);
+    let file = tool_open_file("native-forwarded", "pdf", &native_destination_fixture());
+    let mut events = vec![crate::OsEvent::OpenArguments(vec![format!("{}#nameddest=chapterTwo", file.0.display())])];
+    h.state_mut().os_events = Some(Box::new(move || std::mem::take(&mut events)));
+    h.step();
+    held.wait_started();
+    held.complete(&mut h);
+    assert_eq!(h.state().views.len(), 2);
+    assert_eq!(h.state().views[1].current, 1);
+    assert_eq!(h.state().session.get(h.state().views[1].id).unwrap().path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
+}
+
+#[test]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+fn a_named_remote_link_uses_the_same_bounded_destination_lookup() {
+    let (mut h, _) = harness();
+    let held = HeldToolOpen::install(&mut h);
+    let file = tool_open_file("remote-named", "pdf", &native_destination_fixture());
+    let source = h.state().views[0].id;
+    h.state_mut().open_remote_link(source, file.0.to_string_lossy().into_owned(), pdfcraft_render::RemoteDestination::Named("chapterTwo".into()));
+    h.state_mut().resolve_pending_remote_link(true);
+    held.wait_started();
+    held.complete(&mut h);
+    assert_eq!(h.state().views.len(), 2);
+    assert_eq!(h.state().views[0].current, 0);
+    assert_eq!(h.state().views[1].current, 1);
+}
diff --git a/crates/ui-egui/src/platform/pickers.rs b/crates/ui-egui/src/platform/pickers.rs
--- a/crates/ui-egui/src/platform/pickers.rs
+++ b/crates/ui-egui/src/platform/pickers.rs
@@ -373,7 +373,7 @@ mod tests {
             path: None,
             stamp: None,
             activate: true,
-            after_open: None,
+            after_open: Vec::new(),
             recovered: None,
             then: None,
         });
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -312,7 +312,7 @@ pub struct PasswordPrompt {
     /// the document is unlocked.
     pub(crate) then: Option<&'static str>,
     /// A link destination to apply after the linked PDF is unlocked.
-    pub(crate) after_open: Option<pdfcraft_render::RemoteDestination>,
+    pub(crate) after_open: Vec<pdfcraft_render::RemoteDestination>,
 }
 
 #[cfg(not(target_arch = "wasm32"))]
@@ -323,7 +323,7 @@ pub(crate) struct OpenRequest {
     pub(crate) input: open_worker::Input,
     pub(crate) password: Option<String>,
     pub(crate) activate: bool,
-    pub(crate) after_open: Option<pdfcraft_render::RemoteDestination>,
+    pub(crate) after_open: Vec<pdfcraft_render::RemoteDestination>,
     pub(crate) recovered: Option<RecoveryMeta>,
     pub(crate) then: Option<&'static str>,
 }
diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs
--- a/crates/ui-egui/tests/it/control.rs
+++ b/crates/ui-egui/tests/it/control.rs
@@ -1017,16 +1017,17 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
     }
     let (mut h, c) = harness();
     ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
-    // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic
-    // ("編集(E)", "環境設定(P)…"), so match by the start of the label.
+    // The platform layout moves Preferences into the application menu on macOS.
+    // Its platform label is Settings; other targets keep Preferences in Edit.
+    // Match prefixes to allow mnemonic suffixes.
     let menu_row = |text: &'static str| {
         move |n: &egui_kittest::kittest::AccessKitNode<'_>| {
             n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text))
         }
     };
-    h.get_by(menu_row("編集")).click();
+    h.get_by(menu_row(if cfg!(target_os = "macos") { "PdfCraft" } else { "編集" })).click();
     h.run_steps(2);
-    h.get_by(menu_row("環境設定")).click();
+    h.get_by(menu_row(if cfg!(target_os = "macos") { "Settings" } else { "環境設定" })).click();
     h.run_steps(2);
     for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")]
     {
diff --git a/docs/launch-destinations.md b/docs/launch-destinations.md
new file mode 100644
--- /dev/null
+++ b/docs/launch-destinations.md
@@ -0,0 +1,60 @@
+# Open a PDF at a requested destination
+
+The desktop launcher accepts a physical page or a named PDF destination after the filename.
+This lets another application open its manual at the requested help topic.
+Quote the complete argument when its path contains spaces:
+
+```sh
+pdfcraft "manual with spaces.pdf#nameddest=chapterTwo"
+pdfcraft "manual.pdf#page=2"
+pdfcraft "manual.pdf#page=2" "appendix.pdf#nameddest=index"
+```
+
+`page` is a physical page number starting at 1, independent of printed page labels.
+`nameddest` identifies a destination stored in the PDF, including its supported position and zoom.
+These parameters follow [RFC 8118 section 3](https://www.rfc-editor.org/rfc/rfc8118.html#section-3).
+Several parameters can be separated by `&` or `#` and are applied from left to right.
+An unresolved later destination leaves the last successful view in place and reports a notice.
+Other parameters in a recognized launch fragment are ignored; fragment parameters do not run scripts, import form data or open another URL.
+
+## Paths and escaping
+
+Plain filesystem paths retain literal percent and hash characters.
+Only the filesystem path is made absolute; navigation names are never normalized as paths.
+An existing complete filename containing `#` opens as a literal filename.
+When a navigation suffix follows such a filename, the longest existing file prefix wins.
+A plain path suffix must contain `page=` or `nameddest=` to be recognized as navigation; other hash suffixes remain literal path characters.
+
+Local `file://` URIs separate the fragment before decoding the filename.
+Use `%23` for a literal hash in a URI filename:
+
+```sh
+pdfcraft "file:///home/me/manual%23draft.pdf#page=2"
+pdfcraft "file:///C:/Docs/manual%20with%20spaces.pdf#nameddest=chapterTwo"
+```
+
+Percent-escape fragment delimiters inside a destination name: `%26` for `&`, `%23` for `#`, and `%25` for `%`.
+Values are decoded once, and `+` remains a literal plus character.
+URI-encoded text names use UTF-8; lookup also recognizes the PDF's supported text-string encodings.
+An exact raw key takes precedence over an alternative text encoding.
+
+On Windows, Linux and BSD, a second launch can hand the request to the running app.
+Each file keeps its own destination and the sending process's working directory.
+Navigation survives background preparation, password retries and focusing an already-open tab.
+The recent-files list and saved document path contain the actual filename, without the navigation suffix.
+Opening or navigating does not edit the PDF.
+
+File pickers, Apple-event file paths, session paths, `pdfcraft-cli` document tools and the UI control channel's `ui.open` continue to accept literal filesystem paths.
+They do not interpret launcher suffixes.
+Use the desktop launch command for this workflow; the opt-in UI control channel can inspect its result with `state` and `screenshot`.
+
+## Limits
+
+A launch argument is limited to 1 MiB and its decoded absolute path to 128 KiB.
+The decoded parameter values are limited to 64 KiB in total, with allowance for their percent-encoded representation, and the parameter count to 64.
+An ambiguous plain argument is limited to 64 hash boundaries; a local file URI can escape literal filename hashes.
+A destination name is limited to 1,024 UTF-8 bytes.
+Malformed page numbers and invalid or oversized names are refused with a readable error.
+Missing or out-of-range destinations leave the document open and report a notice.
+Named lookup has depth, traversal, comparison and decoding budgets; an incomplete search does not choose a different fallback destination.
+The named-destinations editing panel and other PDF opening parameters remain separate work.
diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md
--- a/docs/pdf-spec-parity.md
+++ b/docs/pdf-spec-parity.md
@@ -1,5 +1,6 @@
 # PDF specification and standards parity
 
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M3: desktop launch and remote-link named destinations use bounded lookup; remaining opening parameters and estimates unchanged)
 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (§14.3: custom properties edited in Document Properties) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
 
 How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against
@@ -81,7 +82,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2
 | Action | Acrobat | PdfCraft |
 |---|---|---|
 | GoTo | yes | yes (page with Fit; named destinations and zoom partial) |
-| GoToR | yes | partial: local PDF paths open in a PdfCraft tab; `/F` strings and file-spec dictionaries (`/UF`, then `/F`), source-relative paths, and explicit numeric-page destinations are supported. Automation uses the same resolver and `--root` confinement. Named destinations open at the target's default view with a warning; `/NewWindow` is a tab hint. Network/URL file systems are refused. |
+| GoToR | yes | partial: local PDF paths open in a PdfCraft tab; `/F` strings and file-spec dictionaries (`/UF`, then `/F`), source-relative paths, and explicit numeric-page destinations are supported. Automation uses the same resolver and `--root` confinement. Desktop named destinations resolve through bounded lookup; standalone `link_open` reports their names without a viewport; `/NewWindow` is a tab hint. Network/URL file systems are refused. |
 | GoToE | yes | none (preserved; the Optimizer can remove invalid ones) |
 | Launch | yes (restricted) | none |
 | Thread | yes | none |
@@ -128,6 +129,9 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2
 
 ## Revision history
 
+**2026-10-27 | minor:** Desktop launch destinations and local remote-link named destinations share bounded lookup.
+Other opening parameters and parity estimates are unchanged.
+
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-14 | minor | §12.4.4: `/Trans` and `/Dur` read into `DocInfo::transitions` and reported by `doc_info` |
diff --git a/docs/releasing.md b/docs/releasing.md
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -69,13 +69,20 @@ pinned in `release.yml` and builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUI
 embed its Japanese fonts and fail rather than ship without them (`AGENTS.md` §1.4). Bump the pin
 deliberately.
 
-**OCR models:** every desktop package ships the Scan & OCR models (#103). The packaging scripts call
-`stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to
-fetch every `kind = "model"` file in `ATTRIBUTION.toml` (verified by SHA-256) with its licence text
-and an `ATTRIBUTION.txt`, straight into the package, so packaging needs the network. They go where
-`pdfcraft_ocr::Models::dirs_beside_exe` looks: `models\` beside `pdfcraft.exe` (MSI, portable zip),
-`PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/`
-(deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). The web build doesn't include them yet.
+**OCR models:** desktop packaging stages the Scan & OCR models (#103).
+The packaging scripts call `stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to fetch every `kind = "model"` file in `ATTRIBUTION.toml` and verify its SHA-256.
+The current detection and recognition files are `text-detection.onnx` and `text-recognition.onnx`.
+Each model ships with its licence text and the directory's `ATTRIBUTION.txt`, so packaging needs the network.
+Upstream announced the legacy RTEN download URLs' brownout in [ocrs #282](https://github.com/robertknight/ocrs/issues/282) on 2026-10-27 and directs consumers to the ONNX models.
+
+Runtime discovery prefers a complete ONNX detection/recognition pair within each directory, then a complete legacy `text-detection.rten` / `text-recognition.rten` pair.
+An incomplete ONNX update can therefore use an intact RTEN pair in the same directory.
+Discovery never combines model formats or files from different directories.
+Directory precedence stays the same: `$PDFCRAFT_MODELS`, the release-package locations beside the executable, then the source tree's `assets/models/`.
+The first directory with a complete supported pair wins, even if an earlier legacy pair precedes a later ONNX pair.
+
+The release-package locations are `models\` beside `pdfcraft.exe` (MSI, portable zip), `PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` (deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD).
+The web build does not include these models, and OCR remains limited to the Latin alphabet without accents.
 
 ### macOS
 
diff --git a/docs/roadmap.md b/docs/roadmap.md
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,5 +1,7 @@
 # PdfCraft roadmap: milestones and what's next
 
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M3, #3425: native launch page/named destinations retain per-file navigation and literal filename escaping; estimates unchanged)
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
 > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
 > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
 > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
@@ -193,7 +195,7 @@ total matches the parity estimate.
 | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
 | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
 | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
-| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. |
+| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. |
 | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
 | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
 | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
@@ -242,6 +244,11 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
 
 ## Revision history
 
+**2026-10-27 | minor:** M3, #3425: native launch arguments separate local filenames from ordered page/named destinations.
+Navigation follows its file through forwarding, preparation, password retries and focusing an existing tab.
+Named lookup is bounded and shared with desktop local-PDF links.
+Other opening parameters, the destinations panel and estimates remain unchanged.
+
 **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
 
 **2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms.
@@ -254,6 +261,7 @@ Estimates unchanged.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
 | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. |
 | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
 | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,5 +1,6 @@
 # PdfCraft parity with Adobe Acrobat Pro
 
+> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M3, #3425: native launch page/named destinations retain per-file navigation and literal filename escaping; estimates unchanged)
 > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged)
 
 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -292,6 +293,11 @@ licensed OCR or AI model.
 
 ## Revision history
 
+**2026-10-27 | minor:** M3, #3425: native launch arguments separate local filenames from ordered page/named destinations.
+Navigation follows its file through forwarding, preparation, password retries and focusing an existing tab.
+Named lookup is bounded and shared with desktop local-PDF links.
+Other opening parameters, the destinations panel and estimates remain unchanged.
+
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. |
diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1
--- a/packaging/windows/test-msi.ps1
+++ b/packaging/windows/test-msi.ps1
@@ -288,13 +288,16 @@ $modelsDir = Read-Row 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory`
 Assert-Equal $modelsDir[0] 'INSTALLFOLDER' 'OCR models folder parent'
 # DefaultDir is `models`, or `SHORT|models` with a generated 8.3 name.
 if ($modelsDir[1] -notmatch '(^|\|)models$') { throw "OCR models folder name: '$($modelsDir[1])'" }
-$modelFiles = 0
+$modelFiles = @()
 $view = $Database.OpenView('SELECT `File`.`FileName` FROM `File`, `Component` WHERE `File`.`Component_` = `Component`.`Component` AND `Component`.`Directory_` = ''ModelsFolder''')
 try {
   [void] $view.Execute()
-  while ($record = $view.Fetch()) { if ($record.StringData(1) -match '\.rten$') { $modelFiles++ } }
+  # FileName can contain a generated 8.3 name before the long name: SHORT|long.
+  while ($record = $view.Fetch()) { $modelFiles += ($record.StringData(1) -split '\|')[-1] }
 } finally { [void] $view.Close() }
-if ($modelFiles -lt 2) { throw "expected the OCR models (*.rten) in models\, found $modelFiles" }
+foreach ($model in @('text-detection.onnx', 'text-recognition.onnx')) {
+  Assert-Equal (@($modelFiles | Where-Object { $_ -ceq $model }).Count) 1 "OCR model $model in models\"
+}
 $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse''' 1
 Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog'
 [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database)
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/ui-egui/src/platform/open_worker/tests.rs::native_launch_destination_opens_the_file_and_overrides_its_current_page", "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). Native launch arguments support ordered page/nameddest parameters, local file URI escaping, literal hash filenames, per-file preparation/password retry and forwarded launches; filesystem-path APIs stay literal. See docs/launch-destinations.md."
 
 [[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/src/platform/open_worker/tests.rs::a_named_remote_link_uses_the_same_bounded_destination_lookup", "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. Desktop named remote destinations use bounded lookup and retain their page/view; standalone link_open reports the named target without a viewport. Unresolved destinations keep the current view with a notice; /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"
@@ -6544,8 +6544,8 @@ milestone = "M10"
 status = "shipped"
 commands = ["ocr.recognize"]
 tools = ["ocr_recognize", "ocr_status"]
-evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"]
-notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."
+evidence = ["crates/ocr/src/lib.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/lib.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/lib.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/lib.rs::model_discovery_keeps_legacy_only_installations", "crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"]
+notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."
 
 [[feature]]
 id = "ocr.recognize-range"
