collective

Browse / github.com/storytold/pdfcraft / patch

fix(forms): renaming a radio button to an existing group's name joins that group

fix(forms): renaming a radio button to an existing group's name joins that group ## What this changes Part of #4949: the group name. Today, renaming a radio button in Properties to a name another radio group already has is refused as a duplicate. So two buttons placed separately can never become one group. In a PDF, a radio group is one field whose widgets are its buttons (ISO 32000-2 §12.7.5.2.4). Two fields with one button each are two groups, so naming one after the other has to move its buttons into the other field. `set_props` now does that when both the renamed field and the field it is named after are radio groups. In `author/join.rs`: - the renamed field leaves

Status
open
Score
0.00
Adopters
0 other operators
Author
KenM76 (imported from pull request #4977, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+590 −45 in 10 files
Review
pending
Shared
2026-10-28
Id
3d59695590e64b56eb85b5f0

How it went

No other operator has reported using it yet.

Diff

raw · crates/automation/tests/it/automation.rs crates/forms/src/author.rs crates/forms/src/author/edit.rs crates/forms/src/author/join.rs crates/forms/src/author/lifecycle.rs crates/forms/src/author/tree.rs crates/forms/src/tests.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/tests/it/prepare.rs parity/acrobat-features.toml

diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs--- a/crates/automation/tests/it/automation.rs+++ b/crates/automation/tests/it/automation.rs@@ -4004,6 +4004,29 @@ fn preparing_a_form_through_tools() {     assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_)))); } +#[test]+fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() {+    // #4949: two buttons placed separately are two groups; naming one after the other makes+    // them one group, and a clashing export value is renamed instead of shared.+    let dir = workdir("radio-join");+    let mut a = auto(&dir);+    let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();+    ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" }));+    ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" }));+    ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } }));+    let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" }));+    assert_eq!(r["field"], "size");+    ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" }));+    let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap();+    let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));+    let f = fields["fields"].as_array().unwrap();+    assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["size"], "one group");+    assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value");+    assert_eq!(f[0]["value"], "S", "the group keeps its selection");+    ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } }));+    assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1");+}+ #[test] fn rotating_a_field_through_tools() {     let dir = workdir("rotate-field");diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs--- a/crates/forms/src/author.rs+++ b/crates/forms/src/author.rs@@ -11,6 +11,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs};  mod edit;+mod join; mod lifecycle; mod look; mod tree;diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs--- a/crates/forms/src/author/edit.rs+++ b/crates/forms/src/author/edit.rs@@ -1,3 +1,4 @@+use super::join::{check_join, join_radio_group}; use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; use super::*;@@ -71,6 +72,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S         crate::set_field_actions(doc, name, acts)?;     }     let mut new_name = name.to_string();+    // A radio button renamed to another radio group's name joins that group (#4949). The other+    // properties are written first, to this field, and its buttons then move.+    let mut join = None;     if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {         // A full name under the same parents only renames the field's own part (the editor         // shows nested fields by their full name): it isn't moved, so it keeps its place and@@ -79,7 +83,16 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S             (Some((parents, own)), Some((current, _))) if parents == current => own,             _ => n,         };-        if n.contains('.') {+        let full = match f.name.rsplit_once('.') {+            Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"),+            _ => n.to_string(),+        };+        let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full);+        if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) {+            check_join(doc, &f, group)?;+            join = Some(group.clone());+            new_name = full;+        } else if n.contains('.') {             // A full name: the field moves under the parent fields it names (#2696).             let parts = name_parts(n)?;             if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {@@ -385,7 +398,13 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S     }     // A label-only edit must not replace another widget's custom appearances.     let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);-    redraw_widgets(doc, &new_name, only)?;+    if let Some(group) = &join {+        redraw_widgets(doc, name, only)?;+        join_radio_group(doc, &f, group)?;+        redraw_field(doc, &new_name)?;+    } else {+        redraw_widgets(doc, &new_name, only)?;+    }     if props.calculate.is_some() {         crate::recalculate(doc)?;     }diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rsnew file mode 100644--- /dev/null+++ b/crates/forms/src/author/join.rs@@ -0,0 +1,129 @@+//! Renaming a radio button to an existing radio group's name makes it a button of that group+//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2+//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state.++use std::collections::{HashMap, HashSet};++use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers};+use super::tree::{detach, list_of};+use super::*;++/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA`+/// keeps only the widget's own triggers.+const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"];++/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2).+fn has_opt(doc: &Document, field: ObjRef) -> bool {+    doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt"))+}++/// Refuse what [`join_radio_group`] can't do, before anything is written.+pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> {+    for x in [f, target] {+        if has_opt(doc, x.obj) {+            return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name));+        }+        if x.widgets.is_empty() {+            return invalid(format!("{} has no button", x.name));+        }+    }+    if target.locked() {+        return invalid(format!("{} is locked: unlock it first", target.name));+    }+    Ok(())+}++/// Move every button of radio field `f` into radio field `target`. Each keeps its export value+/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a+/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s+/// selected value. `f` leaves the form; its own value and field-level properties go with it,+/// and `target`'s stay. Call [`check_join`] first.+pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> {+    let theirs: HashSet<String> = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect();+    let mut taken = theirs.clone();+    taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone()));+    // The group's value counts only when one of its buttons has it.+    let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned();+    // Each clashing value of `f` gets one fresh name, shared by every button that had it. Of+    // `taken.len() + 1` names at least one is free, so the search always ends in one.+    let mut renamed: HashMap<String, String> = HashMap::new();+    for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) {+        if renamed.contains_key(old) {+            continue;+        }+        let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else {+            return invalid(format!("no free export value for {old}"));+        };+        taken.insert(fresh.clone());+        renamed.insert(old.to_owned(), fresh);+    }+    // A group that is its own one button gets a field with a separate widget first, so it can+    // hold more than one.+    if let [only] = target.widgets.as_slice()+        && only.obj == target.obj+    {+        split_merged(doc, target, only)?;+    }+    detach(doc, f.obj)?;+    // `f` is no longer a field, even when it stays on as a widget.+    drop_from_calculation_order(doc, |r| r == f.obj)?;+    let mut kids = Vec::with_capacity(f.widgets.len());+    for w in &f.widgets {+        let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default();+        if w.obj == f.obj {+            for k in FIELD_ONLY_KEYS {+                d.remove(k);+            }+            let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());+            match aa.as_ref().map(split_triggers) {+                Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)),+                Some((None, _)) => {+                    d.remove(b"AA");+                }+                None => {}+            }+        }+        let mut state = w.on_state.clone();+        if let Some(old) = w.on_state.as_deref()+            && let Some(fresh) = renamed.get(old)+            && rename_state(doc, &mut d, old, fresh)+        {+            state = Some(fresh.clone());+        }+        let on = state.is_some() && state == selected;+        let shown = match (&state, on) {+            (Some(s), true) => Object::Name(crate::name_bytes(s)),+            _ => Object::name("Off"),+        };+        d.set(b"AS".to_vec(), shown);+        d.set(b"Parent".to_vec(), Object::Ref(target.obj));+        doc.set(w.obj, Object::Dict(d));+        kids.push(Object::Ref(w.obj));+    }+    // `/Kids` may be an indirect array: read through it so the group's own buttons stay.+    let mut list = list_of(doc, target.obj, b"Kids");+    list.extend(kids);+    doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?;+    Ok(())+}++/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`,+/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other+/// users. Whether `/N` had the state: when it didn't, nothing is changed.+fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool {+    let (old, new) = (crate::name_bytes(old), crate::name_bytes(new));+    let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false };+    let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned());+    if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) {+        return false;+    }+    for key in [&b"N"[..], b"D", b"R"] {+        let Some(mut sub) = states(&ap, key) else { continue };+        if let Some(v) = sub.remove(&old) {+            sub.set(new.clone(), v);+            ap.set(key.to_vec(), Object::Dict(sub));+        }+    }+    d.set(b"AP".to_vec(), Object::Dict(ap));+    true+}diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs--- a/crates/forms/src/author/lifecycle.rs+++ b/crates/forms/src/author/lifecycle.rs@@ -215,7 +215,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {  /// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or /// by reference.-fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {+pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {     let Some(root) = doc.root() else { return Ok(()) };     let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };     let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) };@@ -237,7 +237,23 @@ fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool }  /// Widget-only keys: they stay with each widget when a merged field is split.-const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"];+const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"];++/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`,+/// `F`, `V`, `C`, Table 199) belong to the field.+const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"];++/// Split additional-actions dictionary `aa` into the widget's triggers and the field's.+/// An empty half is `None`.+pub(super) fn split_triggers(aa: &Dict) -> (Option<Dict>, Option<Dict>) {+    let (mut widget, mut field) = (Dict::new(), Dict::new());+    for (k, v) in aa.iter() {+        let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field };+        to.set(k.clone(), v.clone());+    }+    let some = |d: Dict| (!d.is_empty()).then_some(d);+    (some(widget), some(field))+}  /// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page /// gets a widget at the same place, belonging to the same field, so they share one value.@@ -258,40 +274,7 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul         return Ok(0);     }     // A merged field/widget becomes a field with one widget kid.-    let mut model_ref = model.obj;-    if model.obj == f.obj {-        let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();-        let mut kid = Dict::new();-        for k in WIDGET_KEYS {-            if let Some(v) = d.get(k) {-                kid.set(k.to_vec(), v.clone());-            }-        }-        kid.set(b"Parent".to_vec(), Object::Ref(f.obj));-        let kr = doc.add(Object::Dict(kid));-        doc.update_dict(f.obj, |d| {-            for k in WIDGET_KEYS {-                d.remove(k);-            }-            d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));-        })?;-        // The page lists the widget now, not the field.-        if let Some(p) = model.page {-            let pr = refs[p];-            let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());-            let swap =-                |a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };-            match annots {-                Some(Object::Ref(ar)) => {-                    let a = doc.get(ar).as_array().cloned().unwrap_or_default();-                    doc.set(ar, Object::Array(swap(a)));-                }-                Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,-                _ => {}-            }-        }-        model_ref = kr;-    }+    let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj };     let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default();     let mut added = Vec::new();     for p in &targets {@@ -324,3 +307,52 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul     redraw_field(doc, name)?;     Ok(added.len()) }++/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that+/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid.+pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result<ObjRef, FormError> {+    let refs = page_refs(doc);+    let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();+    let mut kid = Dict::new();+    for k in WIDGET_KEYS {+        if let Some(v) = d.get(k) {+            kid.set(k.to_vec(), v.clone());+        }+    }+    // The widget's own triggers go with it; the field's stay.+    let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());+    let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default();+    if let Some(w) = widget_aa {+        kid.set(b"AA".to_vec(), Object::Dict(w));+    }+    kid.set(b"Parent".to_vec(), Object::Ref(f.obj));+    let kr = doc.add(Object::Dict(kid));+    doc.update_dict(f.obj, |d| {+        for k in WIDGET_KEYS {+            d.remove(k);+        }+        if aa.is_some() {+            match field_aa {+                Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)),+                None => {+                    d.remove(b"AA");+                }+            }+        }+        d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));+    })?;+    // The page lists the widget now, not the field.+    if let Some(&pr) = model.page.and_then(|p| refs.get(p)) {+        let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());+        let swap = |a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };+        match annots {+            Some(Object::Ref(ar)) => {+                let a = doc.get(ar).as_array().cloned().unwrap_or_default();+                doc.set(ar, Object::Array(swap(a)));+            }+            Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,+            _ => {}+        }+    }+    Ok(kr)+}diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs--- a/crates/forms/src/author/tree.rs+++ b/crates/forms/src/author/tree.rs@@ -187,7 +187,7 @@ pub(super) fn clash(name: &str, other: &str) -> Option<String> { }  /// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field).-fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {+pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {     let d = doc.get(holder).as_dict().cloned().unwrap_or_default();     d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() }diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs--- a/crates/forms/src/tests.rs+++ b/crates/forms/src/tests.rs@@ -4577,3 +4577,275 @@ fn set_values_reads_the_form_once_not_per_value() {     let reads = form_reads() - before;     assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); }++/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`).+fn two_radio_groups(a: &str, b: &str) -> Document {+    let mut doc = one_page();+    let radio = |export: &str| NewField::Radio { group: None, export: export.into() };+    assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1");+    assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2");+    doc+}++fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result<String, FormError> {+    set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() })+}++fn page_widgets(doc: &Document) -> Vec<ObjRef> {+    let page = page_refs(doc)[0];+    let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap();+    doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect()+}++/// #4949: a radio button given its group's name is one of the group's buttons, so the group can+/// be built up by naming buttons alike, as in Acrobat.+#[test]+fn a_radio_button_renamed_to_a_groups_name_joins_the_group() {+    let mut doc = two_radio_groups("Yes", "No");+    set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap();+    let before = page_widgets(&doc);+    assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1");+    assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were");+    let mut doc = reopen(&doc);+    let all = fields(&doc);+    assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Group1"]);+    let group = field(&all, "Group1");+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);+    assert_eq!(group.value, ["Yes"], "the group keeps its value");+    assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Yes".into()), Some("Off".into())]);+    assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::<Vec<_>>());+    // Choosing the moved button turns the other off: they are one group.+    set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap();+    let group = field(&fields(&doc), "Group1").clone();+    assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Off".into()), Some("No".into())]);+}++/// A joining button whose export value a button of the group already has takes the next free+/// "ChoiceN", in its appearance states too, so the two can be chosen apart.+#[test]+fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() {+    let mut doc = two_radio_groups("Choice1", "Choice1");+    rename_to(&mut doc, "Group2", "Group1").unwrap();+    let doc = reopen(&doc);+    let all = fields(&doc);+    let group = field(&all, "Group1");+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2"]);+    let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();+    let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap();+    let normal = doc.resolve(&normal).as_dict().unwrap().clone();+    assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1"));+}++/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it.+#[test]+fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() {+    let mut doc = two_radio_groups("Yes", "No");+    let group = field(&fields(&doc), "Group1").obj;+    let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap();+    let held = doc.add(kids);+    doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap();+    rename_to(&mut doc, "Group2", "Group1").unwrap();+    let doc = reopen(&doc);+    let all = fields(&doc);+    assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);+}++/// Buttons of the joining group that share an export value (a button repeated on another page,+/// on together) still share it after the join, under one fresh name.+#[test]+fn joining_radio_buttons_that_share_a_value_keep_sharing_it() {+    let mut doc = two_radio_groups("Choice1", "Choice1");+    let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();+    let mut p = pdfcraft_cos::Dict::new();+    p.set(b"Type".to_vec(), Object::name("Page"));+    p.set(b"Parent".to_vec(), Object::Ref(pages));+    p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));+    let r = doc.add(Object::Dict(p));+    doc.update_dict(pages, |d| {+        let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone();+        kids.push(Object::Ref(r));+        d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));+        d.set(b"Kids".to_vec(), Object::Array(kids));+    })+    .unwrap();+    assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1);+    rename_to(&mut doc, "Group2", "Group1").unwrap();+    let doc = reopen(&doc);+    let all = fields(&doc);+    let group = field(&all, "Group1");+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2", "Choice2"]);+}++/// One page holding radio fields that are each their own only widget, as other writers make+/// one-button groups: `(name, export value, x)`.+fn merged_radios(specs: &[(&str, &str, f64)]) -> Document {+    let mut doc = one_page();+    let page = page_refs(&doc)[0];+    let af = crate::author::ensure_form(&mut doc).unwrap();+    let mut made = Vec::new();+    for &(name, state, x) in specs {+        let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));+        let mut n = pdfcraft_cos::Dict::new();+        n.set(state.as_bytes().to_vec(), Object::Ref(look));+        n.set(b"Off".to_vec(), Object::Ref(look));+        let mut ap = pdfcraft_cos::Dict::new();+        ap.set(b"N".to_vec(), Object::Dict(n));+        let mut d = pdfcraft_cos::Dict::new();+        d.set(b"Type".to_vec(), Object::name("Annot"));+        d.set(b"Subtype".to_vec(), Object::name("Widget"));+        d.set(b"FT".to_vec(), Object::name("Btn"));+        d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO)));+        d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name));+        d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()]));+        d.set(b"P".to_vec(), Object::Ref(page));+        d.set(b"AP".to_vec(), Object::Dict(ap));+        d.set(b"AS".to_vec(), Object::name("Off"));+        let r = doc.add(Object::Dict(d));+        doc.update_dict(af, |a| {+            let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default();+            list.push(Object::Ref(r));+            a.set(b"Fields".to_vec(), Object::Array(list));+        })+        .unwrap();+        made.push(r);+    }+    doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap();+    doc+}++/// A radio field that is its own (only) widget, as other writers make one-button groups, can+/// both join a group and be joined.+#[test]+fn radio_fields_that_are_their_own_widget_join_and_are_joined() {+    let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);+    assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo");+    let doc = reopen(&doc);+    let all = fields(&doc);+    assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["solo"]);+    let group = field(&all, "solo");+    assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["A", "B"]);+    // The joined field's widget became a kid; the moved one shed its field entries.+    let kids: Vec<ObjRef> = group.widgets.iter().map(|w| w.obj).collect();+    assert!(!kids.contains(&group.obj));+    let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();+    assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj));+    let mut on_page = page_widgets(&doc);+    on_page.sort();+    let mut kids = kids;+    kids.sort();+    assert_eq!(on_page, kids, "the page lists the widgets, not the field");+}++/// A group that is its own only widget keeps its button's actions when another button joins+/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own.+#[test]+fn a_joined_merged_groups_button_keeps_its_actions() {+    let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);+    let solo = field(&fields(&doc), "solo").obj;+    let js = |code: &str| {+        let mut a = pdfcraft_cos::Dict::new();+        a.set(b"S".to_vec(), Object::name("JavaScript"));+        a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));+        Object::Dict(a)+    };+    let mut aa = pdfcraft_cos::Dict::new();+    aa.set(b"Fo".to_vec(), js("focus();"));+    aa.set(b"V".to_vec(), js("validate();"));+    let aa = doc.add(Object::Dict(aa));+    doc.update_dict(solo, |d| {+        d.set(b"A".to_vec(), js("up();"));+        d.set(b"AA".to_vec(), Object::Ref(aa));+    })+    .unwrap();+    rename_to(&mut doc, "other", "solo").unwrap();+    let doc = reopen(&doc);+    assert_eq!(+        field_actions(&doc, "solo").unwrap(),+        [(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))]+    );+    let group = field(&fields(&doc), "solo").clone();+    let fd = doc.get(group.obj).as_dict().unwrap().clone();+    let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone();+    assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger");+    let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();+    let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();+    assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V"));+}++/// A joining button that was its own field keeps its widget triggers and drops the field's.+#[test]+fn a_joining_merged_button_sheds_its_fields_triggers() {+    let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);+    let other = field(&fields(&doc), "other").obj;+    let js = |code: &str| {+        let mut a = pdfcraft_cos::Dict::new();+        a.set(b"S".to_vec(), Object::name("JavaScript"));+        a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));+        Object::Dict(a)+    };+    let mut aa = pdfcraft_cos::Dict::new();+    aa.set(b"U".to_vec(), js("up();"));+    aa.set(b"V".to_vec(), js("validate();"));+    aa.set(b"C".to_vec(), js("calc();"));+    let aa = doc.add(Object::Dict(aa));+    doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap();+    rename_to(&mut doc, "other", "solo").unwrap();+    let doc = reopen(&doc);+    let group = field(&fields(&doc), "solo").clone();+    let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();+    let wd = doc.get(moved.obj).as_dict().unwrap().clone();+    let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();+    assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays");+    let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);+    let other = field(&fields(&once), "other").obj;+    let mut aa = pdfcraft_cos::Dict::new();+    aa.set(b"V".to_vec(), js("validate();"));+    once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap();+    rename_to(&mut once, "other", "solo").unwrap();+    let once = reopen(&once);+    let group = field(&fields(&once), "solo").clone();+    let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();+    let wd = once.get(moved.obj).as_dict().unwrap().clone();+    assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA");+}++/// However many "ChoiceN" names a group already uses, a joining button with a clashing export+/// value gets a free one, so the two buttons can still be chosen apart.+#[test]+fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() {+    let mut doc = two_radio_groups("Choice1", "Choice1");+    let group = field(&fields(&doc), "Group1").clone();+    let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();+    let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));+    let mut kids = vec![Object::Ref(group.widgets[0].obj)];+    for i in 2..=10_000 {+        let mut n = pdfcraft_cos::Dict::new();+        n.set(format!("Choice{i}").into_bytes(), Object::Ref(look));+        n.set(b"Off".to_vec(), Object::Ref(look));+        let mut ap = pdfcraft_cos::Dict::new();+        ap.set(b"N".to_vec(), Object::Dict(n));+        let mut w = model.clone();+        w.set(b"AP".to_vec(), Object::Dict(ap));+        w.remove(b"P");+        kids.push(Object::Ref(doc.add(Object::Dict(w))));+    }+    doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();+    rename_to(&mut doc, "Group2", "Group1").unwrap();+    let group = field(&fields(&doc), "Group1").clone();+    let states: Vec<String> = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect();+    assert_eq!(states.len(), 10_001);+    assert_eq!(states.last().map(String::as_str), Some("Choice10001"));+}++/// Only a radio button joins by name: other clashes are refused as before, and a locked group+/// isn't changed.+#[test]+fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() {+    let mut doc = two_radio_groups("Yes", "No");+    add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap();+    assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group");+    assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field");+    set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap();+    assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group");+    assert_eq!(fields(&doc).len(), 3);+}diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -1527,6 +1527,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {         let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());         // A rename also renames the field in the formulas that read it.         let before = props.name.as_ref().and_then(|_| form(app));+        // The widget being edited, by object: a radio button that joins a group by its new name+        // is appended after the group's own buttons, so its index changes.+        let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj));         if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })             && let Some(i) = app.active         {@@ -1535,7 +1538,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {             let typed = d.name.trim();             let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default();             let name = format!("{prefix}{typed}");-            if let (Some(before), Some(after)) = (before, form(app)) {+            let after = form(app);+            let index =+                widget.and_then(|obj| after.as_ref()?.iter().find(|f| f.name == name)?.widgets.iter().position(|w| w.obj == obj)).unwrap_or(d.widget);+            if let (Some(before), Some(after)) = (before, after) {                 let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();                 if n > 0 {                     app.notify(crate::i18n::trn(@@ -1546,7 +1552,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {                     ));                 }             }-            app.views[i].prepare.selected = Some((name, d.widget));+            app.views[i].prepare.selected = Some((name, index));         }     }     if props_now && let Some(d) = app.comment_props.take() {diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs--- a/crates/ui-egui/tests/it/prepare.rs+++ b/crates/ui-egui/tests/it/prepare.rs@@ -775,14 +775,18 @@ fn actions_tab_edits_the_script_of_an_existing_action() { /// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur /// runs another script. fn chained_action_fixture() -> Vec<u8> {-    let objs = [+    pdf_of(&[         "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>",         "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",         "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",         "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>",         "<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>",         "<< /S /URI /URI (https://example.org) >>",-    ];+    ])+}++/// A PDF of `objs`, numbered from 1, with the first as its catalog.+fn pdf_of(objs: &[&str]) -> Vec<u8> {     let mut out = b"%PDF-1.7\n".to_vec();     let mut offs = Vec::new();     for (i, o) in objs.iter().enumerate() {@@ -798,6 +802,65 @@ fn chained_action_fixture() -> Vec<u8> {     out } +/// Two one-button radio groups placed apart on page 1: "Group1" (export `A`) and "Group2" (`B`).+fn two_radio_groups_fixture() -> Vec<u8> {+    let widget = |parent: u32, x: u32, state: &str| {+        format!(+            "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [{x} 300 {} 314] /P 3 0 R /AS /Off /AP << /N << /{state} 8 0 R /Off 8 0 R >> >> >>",+            x + 14+        )+    };+    let (a, b) = (widget(4, 40, "A"), widget(6, 120, "B"));+    pdf_of(&[+        "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] >> >>",+        "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",+        "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 7 0 R] >>",+        "<< /FT /Btn /Ff 49152 /T (Group1) /Kids [5 0 R] >>",+        &a,+        "<< /FT /Btn /Ff 49152 /T (Group2) /Kids [7 0 R] >>",+        &b,+        "<< /Length 3 >>\nstream\n0 g\nendstream",+    ])+}++/// #4949: renaming a radio button to another group's name moves it into that group, after the+/// group's own buttons. The moved button stays the selected one, so a nudge moves it and not the+/// group's first button, and undo and redo take the rename and the nudge back and forth.+#[test]+fn a_radio_button_joining_a_group_by_name_stays_selected() {+    let mut h = harness_with("radios.pdf", two_radio_groups_fixture());+    h.state_mut().execute("form.prepare");+    h.run_steps(2);+    let rects = |h: &Harness<'static, PdfCraftApp>| {+        let s = h.state();+        let form = &s.session.get(s.views[0].id).unwrap().form;+        form.iter().flat_map(|f| f.widgets.iter().map(|w| w.rect)).collect::<Vec<_>>()+    };+    let start = rects(&h);+    h.state_mut().open_field_props("Group2", 0);+    h.run_steps(2);+    h.state_mut().field_props.as_mut().expect("open").name = "Group1".into();+    h.get_by_label("OK").click();+    h.run_steps(3);+    assert_eq!(names(&h), ["Group1"]);+    assert_eq!(h.state().views[0].prepare.selected, Some(("Group1".to_string(), 1)), "the moved button");+    h.key_press(egui::Key::ArrowRight);+    h.run_steps(2);+    let nudged = rects(&h);+    assert_eq!(nudged[0], start[0], "the group's own button stays");+    assert!((nudged[1][0] - start[1][0] - 1.0).abs() < 0.01, "{:?} → {:?}", start[1], nudged[1]);+    h.state_mut().execute("edit.undo");+    h.state_mut().execute("edit.undo");+    h.run_steps(2);+    assert_eq!(names(&h), ["Group1", "Group2"]);+    assert_eq!(rects(&h), start);+    h.state_mut().execute("edit.redo");+    h.state_mut().execute("edit.redo");+    h.run_steps(2);+    assert_eq!(names(&h), ["Group1"]);+    assert_eq!(rects(&h), nudged);+}+ #[test] fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() {     use egui_kittest::kittest::NodeT;diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -4847,8 +4847,8 @@ milestone = "M6" status = "shipped" commands = ["form.add.radio"] tools = ["form_add_field"]-evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools"]-notes = "new group Group1… or join the selected group with the next ChoiceN"+evidence = ["crates/forms/src/tests.rs::every_field_type_can_be_added_named_and_drawn", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::a_radio_button_renamed_to_a_groups_name_joins_the_group", "crates/forms/src/tests.rs::a_joining_radio_button_with_a_taken_export_value_takes_a_free_one", "crates/forms/src/tests.rs::radio_fields_that_are_their_own_widget_join_and_are_joined", "crates/automation/tests/it/automation.rs::renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools"]+notes = "new group Group1… or join the selected group with the next ChoiceN; renaming a radio button (Properties or form_set_props) to another radio group's name moves its buttons into that group (#4949), a clashing export value becoming the next free ChoiceN; refused when either group lists its values in /Opt; a button's export value is not yet editable in Properties"  [[feature]] id = "form.check-box-style"