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>
This commit is contained in:
FredAppsandLMatt08 committed 2026-11-11 12:55:47 +01:00
1 parent 52ca67c8c4
commit e47e9fbc5e
6 files changed
+111 -12

No files matched your search

+40 -12
View File
@@ -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<Vec<Option<String>>> {
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<Value> {
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::<Result<Vec<_>>>()?,
_ => 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<Value> {
let answers = answers(a)?;
let one_based = |key: &str| -> Result<usize> {
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<Value> = o.requests.iter().chain(&edit.requests).map(request_json).collect();
let alerts: Vec<Value> = 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<Value> {
let list: Vec<Value> = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect();
Ok(json!({ "scripts": list }))
+1
View File
@@ -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)?,
+1
View File
@@ -411,6 +411,7 @@ fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarge
})).collect::<Vec<_>>(),
"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,
+3
View File
@@ -901,6 +901,9 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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"])),
@@ -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<u8> {
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);
}
+1
View File
@@ -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;