Skip to main content

pdfluent_forms/
button.rs

1//! Checkbox, radio button, and push button implementation (B.3 + B.5).
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9use crate::flags::FieldFlags;
10use crate::tree::*;
11
12/// Sub-kind of a button field.
13///
14/// AcroForm models all of checkbox, radio button, and push button as the
15/// same `/Btn` field type, distinguished only by the `Pushbutton` and `Radio`
16/// flags in the field flags word. Use [`button_kind`] to derive this enum
17/// from a [`FieldFlags`] value.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ButtonKind {
20    /// Two-state toggle. Default kind when neither `Pushbutton` nor `Radio`
21    /// flags are set. Drawn as a tickbox; user clicks to flip its value
22    /// between the off-state and an "on" appearance state.
23    Checkbox,
24    /// Mutually-exclusive option within a parent radio group. Selecting one
25    /// radio child automatically de-selects its siblings — see
26    /// [`select_radio`].
27    Radio,
28    /// Click-to-action button without persistent state. Typically wired to
29    /// a JavaScript or submit/reset action via the `/AA` dictionary; its
30    /// `value` is not meaningful as form data.
31    PushButton,
32}
33
34/// Determine button sub-kind from flags.
35pub fn button_kind(flags: FieldFlags) -> ButtonKind {
36    if flags.push_button() {
37        ButtonKind::PushButton
38    } else if flags.radio() {
39        ButtonKind::Radio
40    } else {
41        ButtonKind::Checkbox
42    }
43}
44
45/// Check if a button field is currently "on" (checked/selected).
46pub fn is_checked(tree: &FieldTree, id: FieldId) -> bool {
47    if let Some(ref state) = tree.get(id).appearance_state {
48        return state != "Off";
49    }
50    matches!(tree.effective_value(id), Some(FieldValue::Text(s)) if s != "Off")
51}
52
53/// Get the "on" state name for a button widget.
54pub fn on_state_name(tree: &FieldTree, id: FieldId) -> String {
55    if let Some(ref state) = tree.get(id).appearance_state {
56        if state != "Off" {
57            return state.clone();
58        }
59    }
60    if let Some(FieldValue::Text(s)) = tree.effective_value(id) {
61        if s != "Off" {
62            return s.clone();
63        }
64    }
65    "Yes".into()
66}
67
68/// Toggle a checkbox field. Returns `false` if read-only or not a checkbox.
69pub fn toggle_checkbox(tree: &mut FieldTree, id: FieldId) -> bool {
70    let flags = tree.effective_flags(id);
71    if flags.read_only() || button_kind(flags) != ButtonKind::Checkbox {
72        return false;
73    }
74    let new_state = if is_checked(tree, id) {
75        "Off".to_string()
76    } else {
77        on_state_name(tree, id)
78    };
79    tree.get_mut(id).value = Some(FieldValue::Text(new_state.clone()));
80    tree.get_mut(id).appearance_state = Some(new_state);
81    true
82}
83
84/// Select a radio button, deselecting siblings. Returns `false` if read-only.
85pub fn select_radio(tree: &mut FieldTree, id: FieldId) -> bool {
86    if tree.effective_flags(id).read_only() {
87        return false;
88    }
89    let on_name = on_state_name(tree, id);
90    if let Some(pid) = tree.get(id).parent {
91        let siblings: Vec<FieldId> = tree.get(pid).children.clone();
92        for sib in siblings {
93            if sib != id {
94                tree.get_mut(sib).value = Some(FieldValue::Text("Off".into()));
95                tree.get_mut(sib).appearance_state = Some("Off".into());
96            }
97        }
98        tree.get_mut(pid).value = Some(FieldValue::Text(on_name.clone()));
99    }
100    tree.get_mut(id).value = Some(FieldValue::Text(on_name.clone()));
101    tree.get_mut(id).appearance_state = Some(on_name);
102    true
103}
104
105/// Parsed submit-form action attached to a button.
106///
107/// Produced when the parser encounters an `/A` dictionary with `/S /SubmitForm`.
108/// Triggered when the user clicks a push button configured to send form data
109/// to a remote endpoint.
110#[derive(Debug, Clone)]
111pub struct SubmitAction {
112    /// The submission target URL (`/F` entry of the action). Usually HTTP/HTTPS;
113    /// PDF also allows `mailto:` and FTP URLs.
114    pub url: String,
115    /// Bit flags from the `/Flags` entry controlling submit format (FDF, HTML,
116    /// XFDF, JSON), field inclusion (include vs. exclude), and HTTP method.
117    /// See ISO 32000-2 §12.7.5.2 Table 257 for the bit assignments.
118    pub flags: u32,
119}
120
121/// Parsed reset-form action attached to a button.
122///
123/// Produced when the parser encounters an `/A` dictionary with `/S /ResetForm`.
124/// Triggered when the user clicks a push button configured to clear form
125/// fields back to their default values.
126#[derive(Debug, Clone)]
127pub struct ResetAction {
128    /// Fully-qualified field names (parent.child notation) targeted by the
129    /// reset. Empty means "reset all fields in the form".
130    pub fields: Vec<String>,
131    /// Bit flags controlling include-vs-exclude semantics (`/Fields` is the
132    /// list to reset, or the list to skip, depending on bit 0). See ISO
133    /// 32000-2 §12.7.5.3.
134    pub flags: u32,
135}
136
137/// Icon and caption arrangement for a push button's appearance.
138///
139/// Maps to the `/TP` entry of the button's appearance characteristics
140/// dictionary (`/MK`). Determines whether the button shows text only, an
141/// image only, or both — and where the caption sits relative to the icon.
142/// PDF default when `/TP` is absent or unrecognized is [`Self::CaptionOnly`].
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum IconCaptionLayout {
145    /// `/TP 0` — show only the caption (`/CA`). Icon (if any) is ignored.
146    CaptionOnly,
147    /// `/TP 1` — show only the icon. Caption is ignored.
148    IconOnly,
149    /// `/TP 2` — caption rendered below the icon.
150    CaptionBelow,
151    /// `/TP 3` — caption rendered above the icon.
152    CaptionAbove,
153    /// `/TP 4` — caption rendered to the right of the icon.
154    CaptionRight,
155    /// `/TP 5` — caption rendered to the left of the icon.
156    CaptionLeft,
157    /// `/TP 6` — caption overlaid on top of the icon.
158    CaptionOverlay,
159}
160
161impl From<u32> for IconCaptionLayout {
162    fn from(v: u32) -> Self {
163        match v {
164            1 => Self::IconOnly,
165            2 => Self::CaptionBelow,
166            3 => Self::CaptionAbove,
167            4 => Self::CaptionRight,
168            5 => Self::CaptionLeft,
169            6 => Self::CaptionOverlay,
170            _ => Self::CaptionOnly,
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    fn make_checkbox() -> (FieldTree, FieldId) {
179        let mut tree = FieldTree::new();
180        let id = tree.alloc(FieldNode {
181            partial_name: "cb".into(),
182            alternate_name: None,
183            mapping_name: None,
184            field_type: Some(FieldType::Button),
185            flags: FieldFlags::empty(),
186            value: Some(FieldValue::Text("Off".into())),
187            default_value: None,
188            default_appearance: None,
189            quadding: None,
190            max_len: None,
191            options: vec![],
192            top_index: None,
193            rect: Some([0.0, 0.0, 12.0, 12.0]),
194            appearance_state: Some("Off".into()),
195            on_state: None,
196            page_index: None,
197            parent: None,
198            children: vec![],
199            object_id: None,
200            has_actions: false,
201            mk: None,
202            border_style: None,
203        });
204        (tree, id)
205    }
206
207    #[test]
208    fn checkbox_initially_off() {
209        let (tree, id) = make_checkbox();
210        assert!(!is_checked(&tree, id));
211    }
212    #[test]
213    fn toggle_checkbox_on_off() {
214        let (mut tree, id) = make_checkbox();
215        assert!(toggle_checkbox(&mut tree, id));
216        assert!(is_checked(&tree, id));
217        assert!(toggle_checkbox(&mut tree, id));
218        assert!(!is_checked(&tree, id));
219    }
220    #[test]
221    fn radio_mutual_exclusion() {
222        let mut tree = FieldTree::new();
223        let group = tree.alloc(FieldNode {
224            partial_name: "rg".into(),
225            alternate_name: None,
226            mapping_name: None,
227            field_type: Some(FieldType::Button),
228            flags: FieldFlags::from_bits((1 << 15) | (1 << 14)),
229            value: Some(FieldValue::Text("Off".into())),
230            default_value: None,
231            default_appearance: None,
232            quadding: None,
233            max_len: None,
234            options: vec![],
235            top_index: None,
236            rect: None,
237            appearance_state: None,
238            on_state: None,
239            page_index: None,
240            parent: None,
241            children: vec![],
242            object_id: None,
243            has_actions: false,
244            mk: None,
245            border_style: None,
246        });
247        let mk = |tree: &mut FieldTree, n: &str| -> FieldId {
248            let id = tree.alloc(FieldNode {
249                partial_name: n.into(),
250                alternate_name: None,
251                mapping_name: None,
252                field_type: None,
253                flags: FieldFlags::from_bits((1 << 15) | (1 << 14)),
254                value: Some(FieldValue::Text("Off".into())),
255                default_value: None,
256                default_appearance: None,
257                quadding: None,
258                max_len: None,
259                options: vec![],
260                top_index: None,
261                rect: Some([0.0, 0.0, 12.0, 12.0]),
262                appearance_state: Some("Off".into()),
263                on_state: None,
264                page_index: None,
265                parent: Some(group),
266                children: vec![],
267                object_id: None,
268                has_actions: false,
269                mk: None,
270                border_style: None,
271            });
272            tree.get_mut(group).children.push(id);
273            id
274        };
275        let r1 = mk(&mut tree, "opt1");
276        let r2 = mk(&mut tree, "opt2");
277        assert!(select_radio(&mut tree, r1));
278        assert!(is_checked(&tree, r1));
279        assert!(!is_checked(&tree, r2));
280        assert!(select_radio(&mut tree, r2));
281        assert!(!is_checked(&tree, r1));
282        assert!(is_checked(&tree, r2));
283    }
284}