fix(forms): resolve field and widget references in ResetForm and Hide actions (#5364)

This commit is contained in:
jonathanwxh-cell authored and GitHub committed 2026-11-09 10:05:03 +08:00
1 parent fb9b8bded7
commit a22bede984
2 files changed
+114 -34

No files matched your search

+99 -11
View File
@@ -97,21 +97,13 @@ impl FieldAction {
}
}
fn texts(doc: &Document, o: &Object) -> Vec<String> {
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<FieldAction> {
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 }),
]
);
}
}
+15 -23
View File
@@ -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<String> {
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<String> {