M6: a field merged with a widget reads its widget kids as its buttons, and nameless widgets of an unlisted field are options of its name (form.field-list)

A field dictionary that also carries a zero-area widget and lists widget
kids now has the kids as its buttons (ISO 32000-2 §12.7.4: field and
widget merge only for a single widget). Widget annotations listed beside
named kids, and nameless /Fields entries whose /Parent /Fields doesn't
list, are read as buttons of that parent's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-08 06:33:46 +08:00
1 parent 0ddcc60015
commit 916ca60e80
3 files changed
+124 -7

No files matched your search

+21 -5
View File
@@ -392,7 +392,11 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
if let Some(af) = &af {
for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
if let Some(r) = f.as_ref() {
walk(doc, r, &base, &page_of, &mut seen, &mut out, 0);
// A `/Fields` entry that is a nameless kid of a field `/Fields` doesn't list is
// read through that field, which names it (ISO 32000-2 §12.7.4.2).
let nameless_kid = doc.get(r).as_dict().is_some_and(|d| !d.contains(b"T") && d.contains(b"Parent"));
let start = if nameless_kid { unlisted_root(doc, r, &seen.fields).unwrap_or(r) } else { r };
walk(doc, start, &base, &page_of, &mut seen, &mut out, 0);
}
}
}
@@ -512,10 +516,11 @@ fn walk(
// An explicit zero also clears an inherited limit rather than inheriting it again.
inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0);
}
let kids: Vec<ObjRef> =
let mut kids: Vec<ObjRef> =
d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect();
// Kids with /T are fields; kids without are this field's widgets.
let field_kids: Vec<ObjRef> = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect();
let (field_kids, widget_kids): (Vec<ObjRef>, Vec<ObjRef>) =
kids.iter().copied().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T")));
if !field_kids.is_empty() {
for k in field_kids {
// A fully qualified name is built up the field's own `/Parent` chain (ISO 32000-2
@@ -530,7 +535,13 @@ fn walk(
walk(doc, k, &own, page_of, seen, out, depth + 1);
}
}
return;
// "A field's children in the hierarchy may also include widget annotations" (ISO 32000-2
// §12.7.4): widget annotations listed beside named kids are still this field's widgets,
// read as a field of this name next to the named kids.
kids = widget_kids.into_iter().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.name(b"Subtype") == Some(b"Widget"))).collect();
if kids.is_empty() {
return;
}
}
// A field of no kind this reads is skipped before it claims widgets or scripts, which a later
// field listing them then reads.
@@ -545,7 +556,12 @@ fn walk(
Some(b"Sig") => FieldKind::Signature,
_ => return,
};
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
// A field's widgets are its widget kids. The field dictionary is its own widget only when it
// has none: field and widget are merged "when a field has only a single associated widget
// annotation" (ISO 32000-2 §12.7.4), so a merged dictionary that also lists widget kids is a
// field whose buttons are those kids, not one more (often zero-area) button.
let has_widget_kids = kids.iter().any(|k| doc.get(*k).as_dict().is_some());
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") && !has_widget_kids { vec![r] } else { kids };
let widget_refs: Vec<ObjRef> = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect();
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
+101
View File
@@ -2375,6 +2375,107 @@ fn a_radio_choice_by_the_name_turns_one_button_on_in_kids_without_a_page_that_di
}
}
/// A radio group whose field dictionary is merged with a zero-area widget and also lists four
/// widget kids, as some form exporters write it: field and widget may be merged only "when a field
/// has only a single associated widget annotation" (ISO 32000-2 §12.7.4), so the kids are the
/// buttons. Two such groups: each lists its four options, and choosing one by the name turns on
/// exactly that button, across a save, and leaves the other group alone.
#[test]
fn a_field_merged_with_a_widget_that_lists_widget_kids_has_the_kids_as_buttons() {
const OPTIONS: [&str; 4] = ["Not#20Satisfied", "Somewhat", "Satisfied", "Very#20Satisfied"];
let group = |t: &str, first: u32| {
let kids: Vec<String> = (first..first + 4).map(|n| format!("{n} 0 R")).collect();
format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 0 0] /FT /Btn /Ff 49152 /Kids [{}] /V /Off /T ({t}) >>", kids.join(" "))
};
let kid = |parent: u32, i: usize, y: u32| {
let state = OPTIONS.get(i).copied().unwrap_or("X");
let x = 100 + 100 * u32::try_from(i).unwrap_or(0);
format!(
"<< /Type /Annot /Subtype /Widget /F 4 /FT /Btn /Ff 49152 /Parent {parent} 0 R /Rect [{x} {y} {} {}] /P 3 0 R /AS /Off /AP << /N << /{state} 14 0 R /Off 14 0 R >> >> >>",
x + 12,
y + 12
)
};
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3
group("typeA15[0]", 6), // 4
group("typeA15[1]", 10), // 5
];
objs.extend((0..4).map(|i| kid(4, i, 600))); // 6-9
objs.extend((0..4).map(|i| kid(5, i, 560))); // 10-13
objs.push("<< /Length 0 >>\nstream\n\nendstream".into()); // 14
let mut doc = document(&objs);
let all = fields(&doc);
assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["typeA15[0]", "typeA15[1]"]);
let want: Vec<String> = ["Not Satisfied", "Somewhat", "Satisfied", "Very Satisfied"].map(String::from).to_vec();
for f in &all {
assert_eq!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), want, "{}", f.name);
assert!(f.widgets.iter().all(|w| w.page == Some(0) && w.rect[2] > w.rect[0]), "{}: the kids, on the page", f.name);
}
let st = |doc: &Document, n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec));
for (name, choice, on) in [("typeA15[0]", "Satisfied", 8), ("typeA15[1]", "Very Satisfied", 13), ("typeA15[0]", "Not Satisfied", 6)] {
set_value(&mut doc, name, &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("{name} = {choice}: {e}"));
doc = reopen(&doc);
let first = if name == "typeA15[0]" { 6 } else { 10 };
for n in first..first + 4 {
let want = if n == on { choice.as_bytes().to_vec() } else { b"Off".to_vec() };
assert_eq!(st(&doc, n), Some(want), "{name} = {choice}: widget {n}");
}
}
assert_eq!(st(&doc, 13), Some(b"Very Satisfied".to_vec()), "the other group keeps its choice");
let values: Vec<Vec<String>> = fields(&doc).into_iter().map(|f| f.value).collect();
assert_eq!(values, [vec!["Not Satisfied".to_string()], vec!["Very Satisfied".to_string()]]);
}
/// Radio buttons named `2` listed straight in `/Fields`, some fields of their own (`/T (2)`, no
/// `/Parent`) and some nameless widgets of an unlisted field `2` whose `/Kids` mix both: "A
/// field's children in the hierarchy may also include widget annotations" (ISO 32000-2 §12.7.4).
/// Every option is listed under the name `2`, and each, including the nameless widgets', can be
/// chosen by the name, leaving exactly that button on across a save.
#[test]
fn nameless_widgets_of_an_unlisted_field_are_options_of_its_name() {
let named = |state: &str, x: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 49152 /T (2) /V /Off /AS /Off /Rect [{x} 700 {} 712] /P 3 0 R /AP << /N << /{state} 9 0 R /Off 9 0 R >> >> >>",
x + 12
)
};
let nameless = |state: &str, x: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /Parent 4 0 R /AS /Off /Rect [{x} 700 {} 712] /P 3 0 R /AP << /N << /{state} 9 0 R /Off 9 0 R >> >> >>",
x + 12
)
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), // 3
"<< /FT /Btn /Ff 49152 /T (2) /Kids [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), // 4 unlisted
named("0", 50), // 5
named("1", 100), // 6
nameless("2", 150), // 7
nameless("6", 200), // 8
"<< /Length 0 >>\nstream\n\nendstream".into(), // 9
];
let mut doc = document(&objs);
let all = fields(&doc);
assert!(all.iter().all(|f| f.name == "2"), "{:?}", all.iter().map(|f| &f.name).collect::<Vec<_>>());
let mut options: Vec<String> = all.iter().flat_map(|f| &f.widgets).filter_map(|w| w.on_state.clone()).collect();
options.sort();
assert_eq!(options, ["0", "1", "2", "6"]);
let st = |doc: &Document, n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec));
for (choice, on) in [("6", 8), ("1", 6), ("2", 7), ("0", 5)] {
set_value(&mut doc, "2", &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("2 = {choice}: {e}"));
doc = reopen(&doc);
for n in 5..=8 {
let want = if n == on { choice.as_bytes().to_vec() } else { b"Off".to_vec() };
assert_eq!(st(&doc, n), Some(want), "2 = {choice}: widget {n}");
}
}
}
#[test]
fn locked_fields_only_take_unlocking() {
let mut doc = one_page();
+2 -2
View File
@@ -4590,8 +4590,8 @@ milestone = "M3"
status = "shipped"
commands = ["form.fields"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties", "crates/render/src/inspect/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_radio_option_of_any_field_sharing_the_name_can_be_chosen", "crates/forms/src/tests.rs::a_radio_choice_by_the_name_turns_one_button_on_in_kids_without_a_page_that_disagree"]
notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too; a named kid whose /Parent does not name the field listing it is named up its own /Parent chain; fields sharing a name are listed apart, and a radio option any of them draws is chosen by the name, leaving one button on even when their kids have no /P and were saved both on; every field of the name, and a field listing them that shares it, takes the chosen /V"
evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties", "crates/render/src/inspect/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_named_kid_without_a_parent_keeps_its_own_name", "crates/forms/src/tests.rs::a_radio_option_of_any_field_sharing_the_name_can_be_chosen", "crates/forms/src/tests.rs::a_radio_choice_by_the_name_turns_one_button_on_in_kids_without_a_page_that_disagree", "crates/forms/src/tests.rs::a_field_merged_with_a_widget_that_lists_widget_kids_has_the_kids_as_buttons", "crates/forms/src/tests.rs::nameless_widgets_of_an_unlisted_field_are_options_of_its_name"]
notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too; a named kid whose /Parent does not name the field listing it is named up its own /Parent chain; fields sharing a name are listed apart, and a radio option any of them draws is chosen by the name, leaving one button on even when their kids have no /P and were saved both on; every field of the name, and a field listing them that shares it, takes the chosen /V; a field dictionary merged with a widget that also lists widget kids has those kids as its buttons; widget annotations listed beside named kids, or listed in /Fields under an unlisted /Parent, are read as buttons of that parent's name"
[[feature]]
id = "form.highlight-fields"