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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-01 19:24:24 +08:00
1 parent c42851da1e
commit dbea0793e8
2 files changed
+42 -13

No files matched your search

+24 -13
View File
@@ -60,21 +60,32 @@ pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option<String>)
.collect() .collect()
}) })
.filter(|opt: &Vec<Option<String>>| opt.len() == kids.len()); .filter(|opt: &Vec<Option<String>>| opt.len() == kids.len());
for (i, kid) in kids.iter().enumerate() { // Each widget's (`/PCOn` item, on-state name, `/Opt` entry).
let Some(widget) = doc.resolve(kid).as_dict().cloned() else { continue }; let widgets: Vec<(Option<String>, Option<String>, Option<String>)> = kids
let original = text_of(doc, &widget, b"PCOn"); .iter()
let state = widget .enumerate()
.get(b"AP") .filter_map(|(i, kid)| {
.map(|ap| doc.resolve(ap)) let widget = doc.resolve(kid).as_dict().cloned()?;
.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) let original = text_of(doc, &widget, b"PCOn");
.and_then(|n| doc.resolve(&n).as_dict().cloned()) let state = widget
.and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); .get(b"AP")
if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { .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()); 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. /// The on and off item values of a check box (`/PCItems`), or the widget's on state.
+18
View File
@@ -4145,3 +4145,21 @@ fn clear_password_values_clears_static_xfa_password_fields() {
let packets = read_packets(&doc).unwrap().unwrap(); let packets = read_packets(&doc).unwrap().unwrap();
assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); 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("<form1/>", ["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), "");
}