From dbea0793e8b50445585cd876854e05a334089b80 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:24:24 +0800 Subject: [PATCH] M6: a radio group's XFA data compares every button's on state before any /PCOn radio_dataset_value returned the first widget whose on state or /PCOn equalled the selected state. With states /0 /1 and /PCOn items 1 2, selecting /1 matched the first widget's /PCOn "1" and wrote 1 instead of 2, and strip_xfa's comparison, which uses the same function, could then miss a lost value. The selection is an on-state name, so every widget's state is now compared first and /PCOn only after. Test (fails with the one-pass match restored, writing "1" for /1): the static radio fixture with /PCOn 1 2 writes 1 for /0, 2 for /1, 2 for the item 2, the value itself when nothing matches, and nothing with no selection. This changes the XFA data saved for such groups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/xfa/src/data/values.rs | 37 +++++++++++++++++++++++------------ crates/xfa/src/tests.rs | 18 +++++++++++++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 1a476e1ae1..2fef49e85c 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -60,21 +60,32 @@ pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) .collect() }) .filter(|opt: &Vec>| opt.len() == kids.len()); - for (i, kid) in kids.iter().enumerate() { - let Some(widget) = doc.resolve(kid).as_dict().cloned() else { continue }; - let original = text_of(doc, &widget, b"PCOn"); - let state = widget - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); - if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { + // Each widget's (`/PCOn` item, on-state name, `/Opt` entry). + let widgets: Vec<(Option, Option, Option)> = kids + .iter() + .enumerate() + .filter_map(|(i, kid)| { + let widget = doc.resolve(kid).as_dict().cloned()?; + let original = text_of(doc, &widget, b"PCOn"); + let state = widget + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); let export = exports.as_ref().and_then(|e| e.get(i).cloned().flatten()); - return original.or(export).unwrap_or_else(|| selected.to_string()); - } + Some((original, state, export)) + }) + .collect(); + // `sel` is an on-state name, so every widget's state is compared before any `/PCOn`: with + // states `/0` `/1` and items `1` `2`, the state `1` is the second widget, not the first + // one's item. + let by_state = widgets.iter().find(|(_, state, _)| state.as_deref() == Some(selected)); + let by_item = || widgets.iter().find(|(original, _, _)| original.as_deref() == Some(selected)); + match by_state.or_else(by_item) { + Some((original, _, export)) => original.clone().or_else(|| export.clone()).unwrap_or_else(|| selected.to_string()), + None => selected.to_string(), } - selected.to_string() } /// The on and off item values of a check box (`/PCItems`), or the widget's on state. diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..829bb76853 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4145,3 +4145,21 @@ fn clear_password_values_clears_static_xfa_password_fields() { let packets = read_packets(&doc).unwrap().unwrap(); assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } + +/// The data a radio group writes for its selected on state compares every button's state before +/// any `/PCOn`: with states `/0` `/1` and `/PCOn` items `1` `2`, the state `1` is the second +/// button, whose item is `2` (it used to match the first button's `/PCOn` `1` and write `1`). +/// A selection that is only an item still finds its button. +#[test] +fn a_radio_state_is_matched_before_any_pcon_item() { + let doc = Document::open(Arc::new(crate::fixtures::static_radio_shell_with_pcon("", ["1", "2"]))).unwrap(); + let group = FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].lang[0]".into(), data: FieldData::None }; + let write = |sel: &str| crate::data::radio_dataset_value(&doc, &group, &Some(sel.to_string())); + assert_eq!(write("0"), "1"); + assert_eq!(write("1"), "2"); + // `2` is no state: it is the second button's item. + assert_eq!(write("2"), "2"); + // Neither: written as given; nothing selected: empty. + assert_eq!(write("9"), "9"); + assert_eq!(crate::data::radio_dataset_value(&doc, &group, &None), ""); +}