Skip to content

Commit e92f27f

Browse files
committed
M3: doc_open reads open parameters and link_open applies named destinations (storytold#3425)
Everything is reachable headlessly (AGENTS.md §3), so the open-parameter parser moves from the app to pdfcraft_engine::open_parameters. doc_open now splits `manual.pdf#page=2` the way the app does, only when the whole path names no file, resolving the file part under --root. It reports the destination as link_open does. link_open and doc_open share destination_report, which looks a named destination up in the target's /Names /Dests or catalog /Dests, instead of reporting it as not applied. Raised by CodeRabbit in review of storytold#5049.
1 parent 8225d31 commit e92f27f

11 files changed

Lines changed: 94 additions & 28 deletions

File tree

‎crates/automation/src/documents.rs‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,27 @@ impl Automation {
100100
}
101101

102102
pub(crate) fn doc_open(&mut self, a: &Args) -> Result<Value> {
103-
let path = self.resolve(a.str("path")?, false)?;
103+
let requested = a.str("path")?;
104+
// `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file under
105+
// --root, and where it opens. A path that names a file is never split.
106+
let parameters = pdfcraft_engine::open_parameters::split(requested, |p| self.resolve(p, false).is_ok_and(|p| p.is_file()));
107+
let (requested, destination) = match &parameters {
108+
Some((file, destination)) => (file.as_str(), Some(destination.clone())),
109+
None => (requested, None),
110+
};
111+
let path = self.resolve(requested, false)?;
104112
let bytes = read_input(&path)?;
105113
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
106114
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
107115
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
108-
Ok(summary(doc))
116+
let mut value = summary(doc);
117+
if let Some(destination) = destination {
118+
let (applied, destination, warning) = crate::links::destination_report(doc, destination);
119+
value["destination_applied"] = json!(applied);
120+
value["destination"] = destination;
121+
value["warning"] = json!(warning);
122+
}
123+
Ok(value)
109124
}
110125

111126
pub(crate) fn doc_close(&mut self, a: &Args) -> Result<Value> {

‎crates/automation/src/links.rs‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ use std::path::Path;
1111
use crate::comments::parse_color;
1212
use crate::{Args, Automation, Result, ToolError, failed};
1313

14+
/// Where `destination` puts `doc`, a document just opened from a link or with open parameters:
15+
/// whether it applies, the destination as reported, and a warning when it doesn't. A named
16+
/// destination is looked up in the document, as the app does.
17+
pub(crate) fn destination_report(doc: &pdfcraft_engine::Document, destination: RemoteDestination) -> (bool, Value, Option<&'static str>) {
18+
let (name, page, view) = match destination {
19+
RemoteDestination::Default => return (true, json!({ "kind": "default" }), None),
20+
RemoteDestination::Page { page, view } => (None, page, view),
21+
RemoteDestination::Named(name) => match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) {
22+
Some((page, view)) => (Some(name), page, view),
23+
None => return (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")),
24+
},
25+
};
26+
let mut value = json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) });
27+
if let Some(name) = name {
28+
value["kind"] = json!("named");
29+
value["name"] = json!(name);
30+
}
31+
if page < doc.info.pages.len() { (true, value, None) } else { (false, value, Some("The PDF opened, but the page destination is out of range.")) }
32+
}
33+
1434
fn bad(m: impl Into<String>) -> ToolError {
1535
ToolError::InvalidArgs(m.into())
1636
}
@@ -135,20 +155,7 @@ impl Automation {
135155
let id =
136156
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
137157
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
138-
let (destination_applied, destination_value, warning) = match destination {
139-
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
140-
RemoteDestination::Named(name) => {
141-
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
142-
}
143-
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
144-
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
145-
}
146-
RemoteDestination::Page { page, view } => (
147-
false,
148-
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
149-
Some("The PDF opened, but the link's page destination is out of range."),
150-
),
151-
};
158+
let (destination_applied, destination_value, warning) = destination_report(opened, destination);
152159
let mut display_path = path.to_string_lossy().into_owned();
153160
if let Some(root) = &self.root
154161
&& let Ok(relative) = path.strip_prefix(root)

‎crates/automation/src/tools.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ pub fn tools() -> Vec<ToolDef> {
138138
t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.")
139139
.destructive()
140140
.with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])),
141-
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
141+
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited. A path that ends in open parameters, as help systems pass them (`manual.pdf#page=12`, `#nameddest=Intro` or `#Intro`), opens the file and reports its destination as link_open does; a path that names a file opens as it is.")
142142
.cmd("file.open")
143143
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
144144
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
@@ -652,7 +652,7 @@ pub fn tools() -> Vec<ToolDef> {
652652
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).")
653653
.ro()
654654
.with(schema(json!({ "doc": doc() }), &["doc"])),
655-
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
655+
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. A named destination is looked up in the target, as the app does; one the target doesn't define is reported with a warning. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
656656
.with(schema(
657657
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
658658
&["doc", "page", "index"],

‎crates/automation/tests/it/automation.rs‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ use serde_json::{Value, json};
99

1010
/// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", …
1111
fn fixture(n: usize) -> Vec<u8> {
12-
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
12+
fixture_with(n, "")
13+
}
14+
15+
/// Named destinations for [`fixture_with`]: `chapter` (page 2) in the catalog's /Dests and
16+
/// `Load Flow` (page 3) in the /Names /Dests tree.
17+
const NAMED_DESTS: &str = "/Dests << /chapter [6 0 R /Fit] >> /Names << /Dests << /Names [(Load Flow) [8 0 R /XYZ 0 300 0]] >> >>";
18+
19+
/// [`fixture`] with `catalog` added to the catalog dictionary. Page `i` (0-based) is object `4 + 2i`.
20+
fn fixture_with(n: usize, catalog: &str) -> Vec<u8> {
21+
let mut objs: Vec<Vec<u8>> = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes()];
1322
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
1423
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
1524
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
@@ -5258,6 +5267,36 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
52585267
}
52595268
}
52605269

5270+
#[test]
5271+
fn doc_open_reads_open_parameters_after_the_file_name() {
5272+
// #3425: help systems start a PDF reader with `manual.pdf#nameddest=…` or `#page=…`.
5273+
let (_base, root) = sandbox("open-parameters");
5274+
std::fs::write(root.join("manual.pdf"), fixture_with(3, NAMED_DESTS)).unwrap();
5275+
std::fs::write(root.join("odd#page=2.pdf"), fixture(1)).unwrap();
5276+
let mut a = auto(&root);
5277+
for (path, kind, page) in [("manual.pdf#page=3", "page", 3), ("manual.pdf#nameddest=Load Flow", "named", 3), ("manual.pdf#chapter", "named", 2)] {
5278+
let opened = ok(&mut a, "doc_open", json!({ "path": path }));
5279+
assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("manual.pdf"), Some(3)), "{path}");
5280+
assert_eq!(opened["destination_applied"], true, "{path}: {opened}");
5281+
assert_eq!((opened["destination"]["kind"].as_str(), opened["destination"]["page"].as_u64()), (Some(kind), Some(page)), "{path}");
5282+
}
5283+
for (path, warning) in [("manual.pdf#nameddest=Nowhere", "named destination"), ("manual.pdf#page=9", "out of range")] {
5284+
let opened = ok(&mut a, "doc_open", json!({ "path": path }));
5285+
assert_eq!(opened["destination_applied"], false, "{path}");
5286+
assert!(opened["warning"].as_str().is_some_and(|w| w.contains(warning)), "{path}: {opened}");
5287+
}
5288+
// A file whose own name has a `#` opens as itself, and a plain path reports no destination.
5289+
let opened = ok(&mut a, "doc_open", json!({ "path": "odd#page=2.pdf" }));
5290+
assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("odd#page=2.pdf"), Some(1)));
5291+
assert!(opened.get("destination").is_none(), "{opened}");
5292+
// The file before the `#` must exist, inside --root.
5293+
let before = a.session().docs().len();
5294+
for path in ["missing.pdf#page=2", "../outside/secret.pdf#page=1"] {
5295+
err(&mut a, "doc_open", json!({ "path": path }));
5296+
assert_eq!(a.session().docs().len(), before, "{path}");
5297+
}
5298+
}
5299+
52615300
#[test]
52625301
fn local_gotor_link_open_applies_destinations_and_honors_root() {
52635302
let (base, root) = sandbox("gotor-open");
@@ -5307,6 +5346,13 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
53075346
assert_eq!(opened["destination_applied"], false);
53085347
assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}");
53095348
}
5349+
// A name the target defines is looked up there, as the app does.
5350+
std::fs::write(root.join("named-target.pdf"), fixture_with(3, NAMED_DESTS)).unwrap();
5351+
std::fs::write(root.join("named-found.pdf"), remote_link_pdf("named-target.pdf", Some("(chapter)"))).unwrap();
5352+
let source = ok(&mut a, "doc_open", json!({ "path": "named-found.pdf" }))["doc"].as_u64().unwrap();
5353+
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
5354+
assert_eq!(opened["destination_applied"], true, "{opened}");
5355+
assert_eq!(opened["destination"], json!({ "kind": "named", "name": "chapter", "page": 2, "view": { "kind": "fit" } }));
53105356

53115357
let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
53125358
let documents_before = a.session().docs().len();

‎crates/engine/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
7878
| `catalog` | Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an `Availability` of `Ready`, `Planned(milestone)` or `Provider`. The UI renders what is *not* built yet, honestly |
7979
| `actions` | PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
8080
| `links` | Link creation, properties, and finding URLs in page text |
81+
| `open_parameters` | A path's PDF open parameters (`manual.pdf#nameddest=Intro`, `#page=12`): the file to open and where to show it. The app and `doc_open` both read them |
8182
| `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible |
8283
| `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names |
8384

‎crates/engine/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mod layers;
3737
pub mod links;
3838
pub mod ocr;
3939
mod open;
40+
pub mod open_parameters;
4041
pub mod optimizer;
4142
mod page_filter;
4243
mod pictures;

crates/ui-egui/src/platform/open_parameters.rs renamed to crates/engine/src/open_parameters.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const MAX_CANDIDATES: usize = 32;
1313
/// The file `path` names and the destination its open parameters ask for, when `path` is not a
1414
/// file itself but what comes before one of its `#` is. The first such `#` counts, so a folder or
1515
/// file name may contain `#` too. `None`: open `path` as it is.
16-
pub(crate) fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> {
16+
pub fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> {
1717
if is_file(path) {
1818
return None;
1919
}

‎crates/ui-egui/src/app/files.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ impl PdfCraftApp {
535535
pub(crate) fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome {
536536
// `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file,
537537
// shown at that destination once open. A path that names a file is never split.
538-
let parameters = open_parameters::split(path, |p| std::path::Path::new(p).is_file());
538+
let parameters = pdfcraft_engine::open_parameters::split(path, |p| std::path::Path::new(p).is_file());
539539
let (path, after_open) = match &parameters {
540540
Some((file, destination)) => (file.as_str(), Some(destination.clone())),
541541
None => (path, None),

‎crates/ui-egui/src/lib.rs‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,6 @@ pub use platform::file_watch;
113113
use platform::macos_association;
114114
pub use platform::motion;
115115
#[cfg(not(target_arch = "wasm32"))]
116-
use platform::open_parameters;
117-
#[cfg(not(target_arch = "wasm32"))]
118116
use platform::open_worker;
119117
#[cfg(not(target_arch = "wasm32"))]
120118
use platform::pickers;

‎crates/ui-egui/src/platform.rs‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ pub mod file_watch;
55
pub(super) mod macos_association;
66
pub mod motion;
77
#[cfg(not(target_arch = "wasm32"))]
8-
pub(super) mod open_parameters;
9-
#[cfg(not(target_arch = "wasm32"))]
108
pub(super) mod open_worker;
119
#[cfg(not(target_arch = "wasm32"))]
1210
pub(super) mod pickers;

0 commit comments

Comments
 (0)