Skip to main content

pdfluent_forms/
button.rs

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