From d16cbfb96bcc6ad24eb1bfe55075bcd538a180fb Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:47:51 +0800 Subject: [PATCH] M6: form_fields lists a check box's kids by on state and reports the kid that is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit form_fields listed a check box's options by /Opt export, so kids sharing the on state Yes with exports x and y showed as two options, though filling either ticks both (ISO 32000-2 §12.7.5.2.3, EXAMPLE 2). It now lists one option per distinct on state, named by its export unless two states share one (Table 230 allows that), then by the state. `checked` came from /V. It now follows the kid whose /AS is its on state, which §12.7.5.2.3 makes decide where /AS and /V differ, so it matches what the page shows. Test: automation form_fields on shared-state, /AS-over-/V and shared-export fixtures. Each assertion fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/automation/src/forms.rs | 19 ++++++++--- crates/automation/tests/it/automation.rs | 43 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 7ae41dc8b4..3e15c2f4e3 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -296,13 +296,22 @@ impl Automation { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); } // A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3): - // which one is ticked, and the names form_fill takes to tick each. + // which one is ticked, and the names form_fill takes to tick each. One option + // per distinct on state: kids sharing a state tick together (EXAMPLE 2), so + // their `/Opt` exports are not separate choices. Exports name the options + // unless two states share one (Table 230 allows it); then the states do. FormFieldKind::CheckBox => { let mut seen = std::collections::HashSet::new(); - let options: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).filter(|e| seen.insert(*e)).collect(); - if options.len() > 1 { - o.insert("options".into(), json!(options)); - o.insert("checked".into(), json!(f.value.first().map(|v| f.export_for_state(v)))); + let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect(); + if states.len() > 1 { + let mut exports = std::collections::HashSet::new(); + let by_export = states.iter().all(|s| exports.insert(f.export_for_state(s))); + let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() }; + // The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3 + // makes `/AS` decide where it and `/V` differ. + let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref()); + o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::>())); + o.insert("checked".into(), json!(on.map(name))); } } FormFieldKind::Combo | FormFieldKind::List => { diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e0a0553b9c..3f095e2f3b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2657,6 +2657,49 @@ fn form_fill_ticks_one_kid_of_a_check_box_by_its_on_state() { ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": false } })); assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["checked"], Value::Null); } + +/// `form_fields` on a check box's kids: one option per distinct on state, named by its `/Opt` +/// export unless two states share one, and `checked` follows the kid whose `/AS` is on, which +/// ISO 32000-2 §12.7.5.2.3 makes decide where `/AS` and `/V` differ. +#[test] +fn form_fields_lists_a_check_box_by_on_state_and_reports_the_kid_that_is_on() { + // Each kid is (on state, /AS). + let listed = |name: &str, field: &str, (sa, asa): (&str, &str), (sb, asb): (&str, &str)| { + let kid = |on: &str, state: &str, x: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /{state} /Rect [{x} 250 {} 266] /P 3 0 R /AP << /N << /{on} 8 0 R /Off 8 0 R >> >> >>", + x + 16 + ) + }; + let (wa, wb) = (kid(sa, asa, 10), kid(sb, asb, 40)); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + field, + &wa, + &wb, + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir(name); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_fields", json!({ "doc": doc })); + (r["fields"][0]["options"].clone(), r["fields"][0]["checked"].clone()) + }; + // Kids sharing the on state Yes tick together, whatever their exports: not two options. + let shared = + listed("check-box-shared-state", "<< /FT /Btn /T (pair) /V /Off /Opt [(x) (y)] /Kids [6 0 R 7 0 R] >>", ("Yes", "Off"), ("Yes", "Off")); + assert_eq!(shared, (Value::Null, Value::Null)); + // /V names B, but A's /AS is on: A is the one ticked. + let differ = listed("check-box-as-wins", "<< /FT /Btn /T (pair) /V /B /Kids [6 0 R 7 0 R] >>", ("A", "A"), ("B", "Off")); + assert_eq!(differ, (json!(["A", "B"]), json!("A"))); + // Two on states with one export: the states name the options. + let same = listed("check-box-same-export", "<< /FT /Btn /T (pair) /V /B /Opt [(x) (x)] /Kids [6 0 R 7 0 R] >>", ("A", "Off"), ("B", "B")); + assert_eq!(same, (json!(["A", "B"]), json!("B"))); +} /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test]