M3: a Launch link that names a PDF opens it in PdfCraft (ISO 32000-2 §12.6.4.6) (#4185)

* M3: a Launch link that names a PDF opens it in PdfCraft (#4168)

Documents that cross-reference each other often use Launch actions to
open another PDF next to them (#4168: 268 links such as
..\se\se_ST.pdf). PdfCraft follows no Launch action, so clicking them
did nothing, while the same link written as a GoToR opens.

ISO 32000-2 §12.6.4.6: a Launch action "launches an application or
opens or prints a document". Starting programs is not something a
document should be able to do, but opening a PDF is what a remote
Go-To already does. So a Launch action whose /F names a .pdf file, and
that has no /Win launch parameters (application, operation,
arguments), is read as the remote Go-To to that file's first page
(LinkTarget::RemoteGoTo with launch: true). It then goes through the
same path as one: PDF only, --root confinement, confirmation for
absolute paths, opened as a document and never handed to the OS.
Windows writers separate such paths with \, where §7.11.2.1 uses /, so
a path without any / has its \ read as /. doc_info's link target says
"action": "Launch" or "GoToR".

Tests: launch_actions_that_name_a_pdf_open_it_like_a_remote_link
(relative with \, a file specification, an executable, /Win, a bare
.pdf, a mixed path, a URL file system) and
a_launch_link_to_a_pdf_opens_it_like_a_remote_link (link_open opens
the PDF in the root; a program link opens nothing). Mutants, each
failing: the /Win check dropped, \ always or never read as /, any
extension, a bare ".pdf", case-sensitive ".pdf", Launch not read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: a Launch link with a stray /D still opens its PDF (#4168)

Launch (ISO 32000-2 Table 207) has no /D, but launch_pdf went through
remote_go_to, which rejects the action when /D isn't a valid GoToR
destination. A Launch naming c.pdf with /D 0 stayed Other(Launch).
The /F parsing is now shared (action_file) and Launch ignores /D.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-23 10:42:32 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 20a395c0ae
commit ac41758cf3
5 changed files with 145 additions and 29 deletions
+2 -1
View File
@@ -2598,7 +2598,8 @@ fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::RemoteGoTo { file, destination, new_window } => json!({
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
"action": if *launch { "Launch" } else { "GoToR" },
"remote_document": file,
"destination": match destination {
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
+1 -1
View File
@@ -117,7 +117,7 @@ impl Automation {
.iter()
.find(|link| link.page == page && link.index == index)
.ok_or_else(|| failed(format!("page {page_number} has no link {index_number} (see link_list)")))?;
let LinkTarget::RemoteGoTo { file, destination, new_window } = &link.target else {
let LinkTarget::RemoteGoTo { file, destination, new_window, .. } = &link.target else {
return Err(bad("the selected link is not a supported local PDF GoToR link"));
};
(source.path.clone(), file.clone(), destination.clone(), *new_window)
+27
View File
@@ -4809,6 +4809,33 @@ fn links_through_tools() {
assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_))));
}
/// #4168: a Launch action whose file is a PDF (here with Windows `\` separators, relative to the
/// source as a remote link is) opens that PDF in PdfCraft through link_open, within the root; one
/// naming a program is listed but never followed.
#[test]
fn a_launch_link_to_a_pdf_opens_it_like_a_remote_link() {
let (_base, root) = sandbox("launch-pdf");
std::fs::create_dir_all(root.join("ts")).unwrap();
std::fs::create_dir_all(root.join("se")).unwrap();
std::fs::write(root.join("se/se_B2.pdf"), fixture(2)).unwrap();
// The helper writes a GoToR; the action is renamed (the parser rebuilds the shifted offsets).
let launch = |file: &str| String::from_utf8(remote_link_pdf(file, None)).unwrap().replace("/S /GoToR", "/S /Launch").into_bytes();
std::fs::write(root.join("ts/TS_304.pdf"), launch(r"..\se\se_B2.pdf")).unwrap();
std::fs::write(root.join("ts/program.pdf"), launch("tool.exe")).unwrap();
let mut a = auto(&root);
let source = ok(&mut a, "doc_open", json!({ "path": "ts/TS_304.pdf" }))["doc"].as_u64().unwrap();
let info = ok(&mut a, "doc_info", json!({ "doc": source }));
let link = &info["links"][0]["target"];
assert_eq!((link["action"].as_str(), link["remote_document"].as_str()), (Some("Launch"), Some("../se/se_B2.pdf")), "{info}");
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
assert_eq!(opened["path"].as_str().map(|p| p.replace('\\', "/")).as_deref(), Some("se/se_B2.pdf"), "{opened}");
assert_eq!(opened["document"]["pages"], 2);
let program = ok(&mut a, "doc_open", json!({ "path": "ts/program.pdf" }))["doc"].as_u64().unwrap();
let before = a.session().docs().len();
assert!(matches!(err(&mut a, "link_open", json!({ "doc": program, "page": 1, "index": 1 })), ToolError::InvalidArgs(_) | ToolError::Failed(_)));
assert_eq!(a.session().docs().len(), before, "nothing was opened or launched");
}
#[test]
fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
let (_base, root) = sandbox("gotor-not-a-file");
+113 -25
View File
@@ -325,7 +325,10 @@ pub enum LinkTarget {
preserve_rb: bool,
},
/// A local-file remote Go-To action. The caller resolves `file` relative to the source path.
/// `launch` marks a Launch action that names a PDF file (see `launch_pdf`), opened the same
/// way: as a document in PdfCraft, never by the operating system.
RemoteGoTo {
launch: bool,
file: String,
destination: RemoteDestination,
new_window: Option<bool>,
@@ -1375,6 +1378,7 @@ impl<'a> Inspector<'a> {
}
Some("SetOCGState") => self.layer_state(a),
Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())),
Some(other) => LinkTarget::Other(other.to_string()),
None => return None,
}
@@ -1383,25 +1387,7 @@ impl<'a> Inspector<'a> {
}
fn remote_go_to(&self, action: &Dictionary) -> Option<LinkTarget> {
if self.name(action, b"FS").as_deref() == Some("URL") {
return None;
}
let file = match self.resolve(action.get(b"F").ok()?) {
Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?,
Object::Dictionary(spec) => {
if self.name(spec, b"FS").as_deref() == Some("URL") {
return None;
}
spec.get(b"UF")
.ok()
.and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES))
.or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))?
}
_ => return None,
};
if file.is_empty() {
return None;
}
let file = self.action_file(action)?;
let destination = match action.get(b"D") {
Err(_) => RemoteDestination::Default,
Ok(o) => match self.resolve(o) {
@@ -1428,11 +1414,54 @@ impl<'a> Inspector<'a> {
_ => return None,
},
};
let new_window = match action.get(b"NewWindow").map(|o| self.resolve(o)) {
Some(LinkTarget::RemoteGoTo { launch: false, file, destination, new_window: self.new_window(action) })
}
/// The non-empty local file an action's `/F` names (a string or a file specification), shared
/// by GoToR and Launch. A URL file system (`/FS /URL`) names no local file.
fn action_file(&self, action: &Dictionary) -> Option<String> {
if self.name(action, b"FS").as_deref() == Some("URL") {
return None;
}
let file = match self.resolve(action.get(b"F").ok()?) {
Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?,
Object::Dictionary(spec) => {
if self.name(spec, b"FS").as_deref() == Some("URL") {
return None;
}
spec.get(b"UF")
.ok()
.and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES))
.or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))?
}
_ => return None,
};
(!file.is_empty()).then_some(file)
}
fn new_window(&self, action: &Dictionary) -> Option<bool> {
match action.get(b"NewWindow").map(|o| self.resolve(o)) {
Ok(Object::Boolean(value)) => Some(*value),
_ => None,
};
Some(LinkTarget::RemoteGoTo { file, destination, new_window })
}
}
/// A Launch action (ISO 32000-2 §12.6.4.6, Table 207) whose `/F` names a `.pdf` file, read as
/// the remote Go-To to that file's first page that it amounts to (#4168). Launching anything
/// else, or with the deprecated `/Win` launch parameters (an application, an operation, its
/// arguments), is not followed: a document must not start programs. `\` separators, which
/// Windows writers put in such paths, are read as `/` (§7.11.2.1 separates components with
/// `/`) when the path has no `/` of its own. Launch has no `/D`, so one present (written
/// for GoToR, or junk) is ignored rather than checked as a destination.
fn launch_pdf(&self, action: &Dictionary) -> Option<LinkTarget> {
if action.has(b"Win") {
return None;
}
let file = self.action_file(action)?;
let new_window = self.new_window(action);
let file = if file.contains('/') { file } else { file.replace('\\', "/") };
let pdf = file.rsplit('/').next().is_some_and(|name| name.len() > 4 && name.to_ascii_lowercase().ends_with(".pdf"));
pdf.then_some(LinkTarget::RemoteGoTo { launch: true, file, destination: RemoteDestination::Default, new_window })
}
fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option<String> {
@@ -3119,6 +3148,46 @@ trailer << /Root 1 0 R >>
);
}
/// #4168: a Launch action naming a `.pdf` file is read as the link to that file it amounts
/// to, `\` separators included, and a stray `/D` (which Launch has no use for) doesn't stop
/// it. One naming anything else, with no file, or with `/Win` launch parameters stays an
/// action that isn't followed.
#[test]
fn launch_actions_that_name_a_pdf_open_it_like_a_remote_link() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R] >> endobj\n\
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (..\\\\se\\\\se_ST.pdf) >> >> endobj\n\
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /Launch /F << /Type /Filespec /F (chapters/Two.PDF) >> /NewWindow true >> >> endobj\n\
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /Launch /F (tool.exe) >> >> endobj\n\
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /Launch /F (a.pdf) /Win << /F (cmd.exe) /P (/c x) >> >> >> endobj\n\
34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /Launch /Win << /F (b.pdf) >> >> >> endobj\n\
35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /Launch /F (.pdf) >> >> endobj\n\
36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /Launch /F (dir/a\\\\b.pdf) >> >> endobj\n\
37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /Launch /FS /URL /F (https://example.com/x.pdf) >> >> endobj\n\
38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /Launch /F (c.pdf) /D 0 >> >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let info = inspect(Arc::new(pdf.as_bytes().to_vec()), None).expect("opens");
let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
let launch =
|file: &str, new_window| LinkTarget::RemoteGoTo { launch: true, file: file.into(), destination: RemoteDestination::Default, new_window };
let other = || LinkTarget::Other("Launch".into());
assert_eq!(
targets,
[
launch("../se/se_ST.pdf", None),
launch("chapters/Two.PDF", Some(true)),
other(),
other(),
other(),
other(),
launch("dir/a\\b.pdf", None),
other(),
launch("c.pdf", None),
]
);
}
#[test]
fn links_read_bounded_local_remote_go_to_actions() {
use DestView::{Fit, Xyz};
@@ -3147,6 +3216,7 @@ trailer << /Root 1 0 R >>
let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect();
let huge_page_target = usize::try_from(i64::MAX)
.map(|page| LinkTarget::RemoteGoTo {
launch: false,
file: "chapter.pdf".into(),
destination: RemoteDestination::Page { page, view: Fit },
new_window: None,
@@ -3158,6 +3228,7 @@ trailer << /Root 1 0 R >>
(
1,
LinkTarget::RemoteGoTo {
launch: false,
file: "chapters/remote.pdf".into(),
destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } },
new_window: Some(true),
@@ -3166,16 +3237,33 @@ trailer << /Root 1 0 R >>
(
2,
LinkTarget::RemoteGoTo {
launch: false,
file: "foo.pdf".into(),
destination: RemoteDestination::Page { page: 0, view: Fit },
new_window: Some(false),
}
),
(3, LinkTarget::RemoteGoTo { file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }),
(4, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }),
(3, LinkTarget::RemoteGoTo { launch: false, file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }),
(
4,
LinkTarget::RemoteGoTo {
launch: false,
file: "chapter.pdf".into(),
destination: RemoteDestination::Named("chapter".into()),
new_window: None
}
),
(5, LinkTarget::Other("GoToR".into())),
(6, LinkTarget::Other("GoToR".into())),
(7, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }),
(
7,
LinkTarget::RemoteGoTo {
launch: false,
file: "chapter.pdf".into(),
destination: RemoteDestination::Named("chapter".into()),
new_window: None
}
),
(8, LinkTarget::Other("GoToR".into())),
(9, LinkTarget::Other("GoToR".into())),
(10, huge_page_target),
+2 -2
View File
@@ -1178,8 +1178,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = ["link_open"]
evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords"]
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. URL/network file systems, GoToE, Launch and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit."
evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"]
notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit."
[[feature]]
id = "view.annotation-hover"