diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 80e816977f..b5ccf17b55 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -769,12 +769,13 @@ impl Automation { let name = a.str("field")?; let doc = self.doc(a)?; let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?; + // A field with no widget (one only scripts or calculations use) has nothing to focus. + let widgets = field.widgets.len(); let widget = a.opt_int("widget")?.unwrap_or(1); - let widget = usize::try_from(widget) - .ok() - .and_then(|w| w.checked_sub(1)) - .filter(|w| *w < field.widgets.len().max(1)) - .ok_or_else(|| ToolError::InvalidArgs(format!("widget must be 1 to {} for {name:?}", field.widgets.len().max(1))))?; + let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets { + 0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")), + n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")), + })?; let id = doc.id; let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?; let out = self.session.take_js_output(id); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index df28def241..2fcff5f74e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7387,6 +7387,46 @@ fn xfa_focus_scripts_run_through_form_focus() { } } +/// form_focus refuses a field with no widget, which nothing can focus, instead of reporting a +/// focus change that ran nothing. +#[test] +fn form_focus_refuses_a_field_with_no_widget() { + let dir = workdir("focus-no-widget"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [60 60 200 80] /P 3 0 R >>", + "<< /FT /Tx /T (total) /V (3) >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("total.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "total.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == "total"), "the field without a widget is listed: {fields}"); + for args in [json!({ "doc": doc, "field": "total", "event": "enter" }), json!({ "doc": doc, "field": "total", "event": "enter", "widget": 1 })] { + let e = err(&mut a, "form_focus", args.clone()).to_string(); + assert!(e.contains("\"total\" has no widget to focus"), "{args}: {e}"); + } + assert_eq!( + ok(&mut a, "form_focus", json!({ "doc": doc, "field": "name", "event": "enter" }))["changed"], + false, + "a field with a widget still takes it" + ); +} + /// XFA scripts run through the tools: initialize and calculate on opening, calculations after /// form_fill, and a button's click script through js_run, adding a row that undo takes away. #[test]