mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
20a395c0ae
commit
ac41758cf3
@@ -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" }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user