diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 345c4d2179..faca4140c3 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -392,7 +392,11 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, 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 = + let mut kids: Vec = 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 = 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, Vec) = + 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 = 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 = if d.contains(b"Rect") && !has_widget_kids { vec![r] } else { kids }; let widget_refs: Vec = 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) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 272b2a4243..3566e86dfe 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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 = (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 = 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::>(), ["typeA15[0]", "typeA15[1]"]); + let want: Vec = ["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::>(), 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> = 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 = 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::>()); + let mut options: Vec = 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(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 86a9e1698b..381d2b00e0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"