From ac41758cf3f8744c54a85765a98ac523b3e64797 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:42:32 +0800 Subject: [PATCH] =?UTF-8?q?M3:=20a=20Launch=20link=20that=20names=20a=20PD?= =?UTF-8?q?F=20opens=20it=20in=20PdfCraft=20(ISO=2032000-2=20=C2=A712.6.4.?= =?UTF-8?q?6)=20(#4185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --------- Co-authored-by: Claude Opus 5.5 --- crates/automation/src/lib.rs | 3 +- crates/automation/src/links.rs | 2 +- crates/automation/tests/it/automation.rs | 27 +++++ crates/render/src/inspect.rs | 138 +++++++++++++++++++---- parity/acrobat-features.toml | 4 +- 5 files changed, 145 insertions(+), 29 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 522705bb1..f3effea07 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2598,7 +2598,8 @@ fn info(d: &Document) -> Value { })).collect::>(), "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" }), diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index 8058d5639..41fda9b7d 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -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) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index bcfb5d5c6..f9d163d03 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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"); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 953a96d21..c47d04cdc 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -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, @@ -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 { - 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 { + 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 { + 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 { + 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 { @@ -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), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3532d3e78..cf96bbfd3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"