From e47e9fbc5e72914ed37065b970a38c2beb1c1110 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:55:47 +0100 Subject: [PATCH] automation: link_run_javascript, open_doc requests, links' JavaScript in doc_info Ported from #2081 on main's ScriptSession: link_run_javascript runs a link's action with ordered answers (as js_run takes them) and reports its requests without carrying them out. Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- crates/automation/src/a11y/form_tools.rs | 52 +++++++++++---- crates/automation/src/dispatch.rs | 1 + crates/automation/src/json.rs | 1 + crates/automation/src/tools.rs | 3 + crates/automation/tests/it/link_javascript.rs | 65 +++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + 6 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 crates/automation/tests/it/link_javascript.rs diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 31234a28ed..fa07b686de 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -16,11 +16,28 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Beep => json!({ "beep": true }), R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), + R::OpenDoc(path) => json!({ "open_doc": path }), R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }), R::ShowPanel(p) => json!({ "panel": p.id() }), } } +/// The optional `answers` argument: the prompts' answers in order (null cancels one). +fn answers(a: &Args) -> Result>> { + match a.0.get("answers") { + None => Ok(Vec::new()), + Some(Value::Array(values)) if values.len() <= 32 => values + .iter() + .map(|v| match v { + Value::Null => Ok(None), + Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())), + _ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())), + }) + .collect(), + _ => Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())), + } +} + /// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), /// icon and buttons. pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { @@ -32,18 +49,7 @@ impl Automation { /// messages, console lines, requests, error, the failing document scripts' errors and result. pub(crate) fn js_run(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; - let answers = match a.0.get("answers") { - None => Vec::new(), - Some(Value::Array(values)) if values.len() <= 32 => values - .iter() - .map(|v| match v { - Value::Null => Ok(None), - Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())), - _ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())), - }) - .collect::>>()?, - _ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())), - }; + let answers = answers(a)?; let identity = a.0.get("field_id") .map(|v| { @@ -66,6 +72,28 @@ impl Automation { )) } + /// `link_run_javascript`: run the JavaScript action of link `index` on page `page` (both + /// 1-based, as link_list gives them), answering its prompts from `answers`, and report what + /// it showed and asked for. A failed script commits nothing (#2081). + pub(crate) fn link_run_javascript(&mut self, a: &Args) -> Result { + let answers = answers(a)?; + let one_based = |key: &str| -> Result { + let n = a.int(key)?; + usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| ToolError::InvalidArgs(format!("{key} must be 1 or more"))) + }; + let (page, index) = (one_based("page")?, one_based("index")?); + let doc = self.doc(a)?; + let id = doc.id; + if !doc.info.links.iter().any(|l| l.page == page && l.index == index && matches!(l.target, pdfcraft_render::LinkTarget::JavaScript(_))) { + return Err(failed(format!("page {} has no JavaScript link {} (see link_list)", page + 1, index + 1))); + } + let (o, edit) = self.session.with_own_js_output(id, |s| s.run_link_javascript(id, page, index, &answers)).map_err(failed)?; + let reqs: Vec = o.requests.iter().chain(&edit.requests).map(request_json).collect(); + let alerts: Vec = o.alerts.iter().chain(&edit.alerts).map(alert_json).collect(); + let console: Vec<&String> = o.console.iter().chain(&edit.console).collect(); + Ok(with_warnings(json!({ "alerts": alerts, "console": console, "requests": reqs, "error": o.error, "result": o.result }), edit.errors)) + } + pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); Ok(json!({ "scripts": list })) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 9644684772..2b5f4a181a 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -733,6 +733,7 @@ impl Automation { "link_add" => self.link_add(&a)?, "link_edit" => self.link_edit(&a)?, "link_delete" => self.link_delete(&a)?, + "link_run_javascript" => self.link_run_javascript(&a)?, "links_from_urls" => self.links_from_urls(&a)?, "links_remove" => self.links_remove(&a)?, "object_list" => self.object_list(&a)?, diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index f612c3050d..a2c4dce823 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -411,6 +411,7 @@ fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarge })).collect::>(), "preserve_rb": preserve_rb, }), + pdfcraft_render::LinkTarget::JavaScript(script) => json!({ "javascript": script }), pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ "action": if *launch { "Launch" } else { "GoToR" }, "remote_document": file, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7d948858cd..9876403a91 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -901,6 +901,9 @@ pub fn tools() -> Vec { }), &["doc", "page", "index"], )), + t("link_run_javascript", "Run a link's JavaScript", "Run the JavaScript action of link `index` on page `page` (1-based, from link_list: index is the link's place among the page's annotations), as clicking it does: event.type Link, event.name Mouse Up, event.target the document, this.pageNum the link's page, in the document's script realm. answers reply to its popup menus, response dialogs and alerts in order (null cancels a menu or response; missing answers do too); a menu with no items answers null without using one. Field changes and resetForm are one undoable step; a script that fails commits nothing and its requests are dropped. Returns alerts, console, requests (page, url, open_doc and others, never carried out), error and result.") + .destructive() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096}} }), &["doc", "page", "index"])), t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), diff --git a/crates/automation/tests/it/link_javascript.rs b/crates/automation/tests/it/link_javascript.rs new file mode 100644 index 0000000000..dc6c42e4e7 --- /dev/null +++ b/crates/automation/tests/it/link_javascript.rs @@ -0,0 +1,65 @@ +//! A link's JavaScript action through the tool table (#2081, ported): link_list shows the +//! script, link_run_javascript runs it with ordered answers and reports requests, never +//! carrying them out. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn link_run_javascript_answers_menus_and_reports_requests() { + let script = "var c = app.popUpMenuEx\\({cName:'Red', cReturn:'r'}, {cName:'Blue', cReturn:'b'}\\); getField\\('log'\\).value = c + ':' + event.type; app.openDoc\\('other.pdf'\\);"; + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"), + "<< /Type /Annot /Subtype /Link /Rect [20 80 180 120] /Border [0 0 0] /A << /S /JavaScript /JS (missing\\(\\);) >> >>".into(), + ]); + let dir = std::env::temp_dir().join(format!("pdfcraft-link-js-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("link.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = tool(&mut a, "doc_open", json!({ "path": "link.pdf" }))["doc"].as_u64().unwrap(); + let info = tool(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info.to_string().contains("\"javascript\""), "links show their script: {info}"); + let log = |a: &mut Automation| { + let f = tool(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap() + }; + + let out = tool(&mut a, "link_run_javascript", json!({ "doc": doc, "page": 1, "index": 2, "answers": ["b"] })); + assert_eq!(out["error"], Value::Null, "{out}"); + assert_eq!(out["requests"], json!([{ "open_doc": "other.pdf" }]), "{out}"); + assert_eq!(log(&mut a), "b:Link"); + + let failed = tool(&mut a, "link_run_javascript", json!({ "doc": doc, "page": 1, "index": 3 })); + assert!(failed["error"].is_string(), "{failed}"); + assert!(a.call("link_run_javascript", &json!({ "doc": doc, "page": 1, "index": 1 })).is_err(), "a widget is no JavaScript link"); + assert!(a.call("link_run_javascript", &json!({ "doc": doc, "page": 0, "index": 2 })).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 35b9d988f7..2b8498f719 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -12,6 +12,7 @@ mod button_caption; mod find_text; mod form_bulk; mod hostile_args; +mod link_javascript; mod list_selection; mod model_3d; mod model_render;