M6: dotted field names make parent fields (#2696)

A field's own (partial) name can't contain a period (ISO 32000-2
§12.7.4.2), so Prepare a form refused MASTER.first. Acrobat reads such a
name as a full name: the field `first` under a parent field `MASTER`.

- add_field: a dotted name makes or joins the parent fields it names.
- set_props: a dotted name is the full name and moves the field there,
  copying what it inherited onto it first; a plain name still renames
  the field within its parent. Either way the new full name may not be
  taken by, or hold or sit inside, another field.
- A field joining an existing parent doesn't take its Ff or Q.
- Field Properties and the Prepare a form name tags show the full name
  instead of the last part.
- Names are capped at 32 parts; empty parts are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-23 10:45:13 +08:00
co-authored by Claude Opus 5.5
parent de91632934
commit f41abf921f
6 changed files with 263 additions and 60 deletions
+61
View File
@@ -3056,3 +3056,64 @@ fn a_skipped_field_leaves_its_widget_to_the_next() {
let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect();
assert_eq!(widgets, [("b", 1)]);
}
/// #2696: a dotted name is a full name. `MASTER.first` is a field `first` under a parent field
/// `MASTER` (a partial name can't contain a period, ISO 32000-2 §12.7.4.2); a second `MASTER.`
/// name joins that parent; renaming to a dotted name moves the field, keeping what it inherited;
/// and a name that would make one field both hold a value and hold fields is refused.
#[test]
fn dotted_field_names_are_made_with_parent_fields() {
let mut doc = fixture();
let top = |doc: &Document| {
let root = doc.root().unwrap();
let af = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")).unwrap();
doc.get(af).as_dict().and_then(|d| d.get(b"Fields").and_then(|o| o.as_array().map(|a| a.len()))).unwrap()
};
let own_name = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of));
let parent_of = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap();
let text = NewField::Text { multiline: false };
let before = top(&doc);
assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some("MASTER.account_firstname")).unwrap(), "MASTER.account_firstname");
add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("MASTER.street1")).unwrap();
assert_eq!(top(&doc), before + 1, "one parent field at the top");
let saved = reopen(&doc);
let all = fields(&saved);
let (a, b) = (field(&all, "MASTER.account_firstname"), field(&all, "MASTER.street1"));
assert_eq!(own_name(&saved, b).as_deref(), Some("street1"), "the field's own name has no period");
let master = parent_of(&saved, a);
assert_eq!(parent_of(&saved, b), master, "both under one MASTER");
assert_eq!(saved.get(master).as_dict().and_then(|d| d.get(b"T").and_then(text_of)).as_deref(), Some("MASTER"));
// Renaming to a dotted name moves the field: zip joins the existing address parent.
assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some("address.zip".into()), ..FieldProps::default() }).unwrap(), "address.zip");
assert_eq!(top(&doc), before, "zip left the top");
let all = fields(&doc);
let (zip, city) = (field(&all, "address.zip"), field(&all, "address.city"));
assert_eq!(parent_of(&doc, zip), parent_of(&doc, city));
assert_eq!(zip.max_len, Some(5), "its own settings move with it");
// What a moved field inherited stays; what a new field would inherit from a parent it
// joins doesn't apply to it.
let address = parent_of(&doc, city);
doc.update_dict(address, |d| {
d.set(b"Ff".to_vec(), Object::Int(flags::READ_ONLY as i64));
d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec())));
})
.unwrap();
let city = field(&fields(&doc), "address.city").clone();
assert!(city.read_only() && city.value == ["Springfield"]);
set_props(&mut doc, "address.city", &FieldProps { name: Some("billing.city".into()), ..FieldProps::default() }).unwrap();
add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("address.street")).unwrap();
let all = fields(&doc);
let moved = field(&all, "billing.city");
assert!(moved.read_only() && moved.value == ["Springfield"], "the moved field keeps what it inherited");
assert!(!field(&all, "address.street").read_only(), "a new field doesn't take its parent's flags");
for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] {
assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}");
assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}");
}
let deep = vec!["p"; 33].join(".");
assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep");
assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken");
}