M6: form_fields lists a check box's kids by on state and reports the kid that is on

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-31 13:59:31 +08:00
1 parent 880ccd4c82
commit d16cbfb96b
2 files changed
+57 -5

No files matched your search

+14 -5
View File
@@ -296,13 +296,22 @@ impl Automation {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
// 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::<Vec<_>>()));
o.insert("checked".into(), json!(on.map(name)));
}
}
FormFieldKind::Combo | FormFieldKind::List => {
+43
View File
@@ -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]