diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5ebe3269cc..6a91220713 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -97,21 +97,13 @@ impl FieldAction { } } -fn texts(doc: &Document, o: &Object) -> Vec { - match &*doc.resolve(o) { - Object::Array(a) => a.iter().filter_map(|x| doc.resolve(x).as_string().map(|s| s.to_text())).collect(), - Object::String(s) => vec![s.to_text()], - _ => Vec::new(), - } -} - fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), - b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), + b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| crate::read::action_fields(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), b"GoTo" => { let dest = d.get(b"D").map(|x| doc.resolve(x))?; @@ -119,8 +111,8 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu FieldAction::GoTo(pages.iter().position(|p| *p == target)?) } b"Hide" => FieldAction::ShowHide { - fields: d.get(b"T").map(|t| texts(doc, t)).unwrap_or_default(), - hide: !matches!(d.get(b"H"), Some(Object::Bool(false))), + fields: d.get(b"T").map(|t| crate::read::action_fields(doc, t)).unwrap_or_default(), + hide: !d.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))), }, b"SubmitForm" => FieldAction::Submit( d.get(b"F") @@ -344,3 +336,99 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reset_and_hide_actions_resolve_hierarchical_widget_and_indirect_field_targets() { + let mut doc = Document::new_empty(); + let rect = || Object::Array(vec![0.into(), 0.into(), 50.into(), 20.into()]); + + // 1. Hierarchical child field `address.city` (merged field + widget). + let address = doc.add(Object::Null); + let mut city = Dict::new(); + city.set(b"Parent".to_vec(), Object::Ref(address)); + city.set(b"FT".to_vec(), Object::name("Tx")); + city.set(b"T".to_vec(), PdfString::text("city")); + city.set(b"Subtype".to_vec(), Object::name("Widget")); + city.set(b"Rect".to_vec(), rect()); + let city = doc.add(Object::Dict(city)); + let mut addr_dict = Dict::new(); + addr_dict.set(b"T".to_vec(), PdfString::text("address")); + addr_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(city)])); + doc.set(address, Object::Dict(addr_dict)); + + // 2. Separated field `phone` whose widget dictionary has `/Parent` instead of `/T`. + let phone = doc.add(Object::Null); + let mut phone_widget = Dict::new(); + phone_widget.set(b"Subtype".to_vec(), Object::name("Widget")); + phone_widget.set(b"Parent".to_vec(), Object::Ref(phone)); + phone_widget.set(b"Rect".to_vec(), rect()); + let phone_widget = doc.add(Object::Dict(phone_widget)); + let mut phone_dict = Dict::new(); + phone_dict.set(b"FT".to_vec(), Object::name("Tx")); + phone_dict.set(b"T".to_vec(), PdfString::text("phone")); + phone_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(phone_widget)])); + doc.set(phone, Object::Dict(phone_dict)); + + // 3. Indirect string target `"email"`. + let email_str = doc.add(Object::String(PdfString::text("email"))); + let targets = Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(email_str)]); + + let mut reset_action = Dict::new(); + reset_action.set(b"S".to_vec(), Object::name("ResetForm")); + reset_action.set(b"Fields".to_vec(), targets.clone()); + let reset_action = doc.add(Object::Dict(reset_action)); + + let indirect_false = doc.add(Object::Bool(false)); + let mut hide_action = Dict::new(); + hide_action.set(b"S".to_vec(), Object::name("Hide")); + hide_action.set(b"T".to_vec(), targets); + hide_action.set(b"H".to_vec(), Object::Ref(indirect_false)); + let hide_action = doc.add(Object::Dict(hide_action)); + + let mut aa = Dict::new(); + aa.set(b"D".to_vec(), Object::Ref(hide_action)); + let mut btn = Dict::new(); + btn.set(b"FT".to_vec(), Object::name("Btn")); + btn.set(b"Ff".to_vec(), Object::Int(1 << 16)); + btn.set(b"T".to_vec(), PdfString::text("btn")); + btn.set(b"Subtype".to_vec(), Object::name("Widget")); + btn.set(b"Rect".to_vec(), rect()); + btn.set(b"A".to_vec(), Object::Ref(reset_action)); + btn.set(b"AA".to_vec(), Object::Dict(aa)); + let btn = doc.add(Object::Dict(btn)); + + let root = doc.root().unwrap(); + let pages_ref = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).unwrap(); + let mut page_dict = Dict::new(); + page_dict.set(b"Type".to_vec(), Object::name("Page")); + page_dict.set(b"Parent".to_vec(), Object::Ref(pages_ref)); + page_dict.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page_dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(btn)])); + let page = doc.add(Object::Dict(page_dict)); + doc.update_dict(pages_ref, |p| { + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut form = Dict::new(); + form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(address), Object::Ref(phone), Object::Ref(btn)])); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); + + let expected = vec!["address.city".to_string(), "phone".to_string(), "email".to_string()]; + let btn_field = fields(&doc).into_iter().find(|f| f.name == "btn").unwrap(); + assert_eq!(btn_field.button, Some(crate::af::ButtonAction::Reset { fields: expected.clone(), exclude: false })); + + let acts = field_actions(&doc, "btn").unwrap(); + assert_eq!( + acts, + vec![ + (Trigger::MouseUp, FieldAction::Reset(expected.clone())), + (Trigger::MouseDown, FieldAction::ShowHide { fields: expected, hide: false }), + ] + ); + } +} diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 16bdae314d..4cb8da3b0f 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -20,17 +20,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); Some(match a.name(b"S")? { b"ResetForm" => { - let fields = a - .get(b"Fields") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match o { - Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), - other => text_of(&doc.resolve(other)), - }) - .collect(); + let fields = a.get(b"Fields").map(|f| action_fields(doc, f)).unwrap_or_default(); let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; af::ButtonAction::Reset { fields, exclude } } @@ -48,18 +38,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) } b"Hide" => { - // /T: a field name, an annotation (or field) reference, or an array of them. - let target = |o: &Object| match o { - Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), - other => text_of(other), - }; - let fields = match a.get(b"T") { - Some(t) => match &*doc.resolve(t) { - Object::Array(items) => items.iter().filter_map(target).collect(), - _ => target(t).into_iter().collect(), - }, - None => Vec::new(), - }; + let fields = a.get(b"T").map(|t| action_fields(doc, t)).unwrap_or_default(); let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); af::ButtonAction::ShowHide { fields, hide } } @@ -91,6 +70,19 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec }) } +/// The field names targeted by a `ResetForm` (`/Fields`) or `Hide` (`/T`) action entry: a field +/// name string, an indirect field or widget dictionary reference, or an array of them. +pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec { + let target = |item: &Object| match item { + Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(item))), + other => text_of(other), + }; + match &*doc.resolve(o) { + Object::Array(items) => items.iter().filter_map(target).collect(), + _ => target(o).into_iter().collect(), + } +} + /// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, /// from the partial names (`/T`) up its `/Parent` chain. fn qualified_name(doc: &Document, r: ObjRef) -> Option {