Skip to main content

pdfluent_forms/
model.rs

1//! Complete AcroForm model export for editor/binding consumption (C-chain).
2//!
3//! [`build_form_model`] flattens a parsed [`FieldTree`] into a list of
4//! [`FormFieldModel`] values — one per *logical* field — with everything an
5//! interactive form UI needs: typed kind, per-page widget rectangles, radio
6//! on-state values, checkbox on-states, choice options, comb/multiline
7//! flags, length limits, access flags and resolved default-appearance info.
8//!
9//! The model is deliberately plain data (no lifetimes, no arena ids) so
10//! facades and language bindings can serialize it as-is.
11
12// Copyright (c) 2026 Innovation Trigger B.V.
13//
14// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
15// the PDFluent Commercial Licence. See the LICENSE file in this repository --
16// that file travels with the copy you received, which a URL does not.
17
18use crate::appearance::parse_da;
19use crate::button::{button_kind, ButtonKind};
20use crate::flags::FieldFlags;
21use crate::tree::{ChoiceOption, FieldId, FieldTree, FieldType, FieldValue, Quadding};
22
23/// The kind of a logical form field, with kind-specific data inline.
24#[derive(Debug, Clone, PartialEq)]
25pub enum FormFieldKind {
26    /// Single-line or multiline text input.
27    Text {
28        /// Multiline flag (`/Ff` bit 13).
29        multiline: bool,
30        /// Comb flag (`/Ff` bit 25) — fixed character cells; only meaningful
31        /// together with `max_len`.
32        comb: bool,
33        /// Password flag (`/Ff` bit 14) — render input masked.
34        password: bool,
35    },
36    /// A two-state checkbox.
37    Checkbox {
38        /// The widget's on-state name (`/AP /N` key); `Yes` when the widget
39        /// declares none. Pass to setters; `Off` is always the off state.
40        on_state: String,
41        /// Whether the box is currently checked.
42        checked: bool,
43    },
44    /// A radio group: one logical field, one widget per option.
45    RadioGroup {
46        /// Export (on-state) value per option, in widget order. The widget
47        /// at `widgets[i]` toggles on when the group value is `options[i]`.
48        options: Vec<String>,
49    },
50    /// Combo box (dropdown), optionally editable.
51    ComboBox {
52        /// Edit flag (`/Ff` bit 19) — free text allowed besides options.
53        editable: bool,
54        /// `/Opt` entries.
55        options: Vec<ChoiceOption>,
56    },
57    /// List box, optionally multi-select.
58    ListBox {
59        /// MultiSelect flag (`/Ff` bit 22).
60        multi_select: bool,
61        /// `/Opt` entries.
62        options: Vec<ChoiceOption>,
63    },
64    /// Push button — no persistent value.
65    PushButton,
66    /// Signature field.
67    Signature,
68}
69
70/// One widget annotation of a logical field.
71#[derive(Debug, Clone, PartialEq)]
72pub struct WidgetModel {
73    /// 0-based page index, when the widget was found on a page.
74    pub page_index: Option<usize>,
75    /// Widget rectangle `[x0, y0, x1, y1]` in PDF user space.
76    pub rect: [f32; 4],
77    /// Button widgets: this widget's on-state name from its `/AP /N`.
78    pub on_state: Option<String>,
79    /// Current appearance state (`/AS`), when present.
80    pub appearance_state: Option<String>,
81}
82
83/// Resolved default-appearance info for a field (from `/DA`, inherited).
84#[derive(Debug, Clone, PartialEq)]
85pub struct DaInfo {
86    /// Font resource name (e.g. `Helv`), without the leading slash.
87    pub font_name: Option<String>,
88    /// Font size in points; `0` means auto-size.
89    pub font_size: f32,
90    /// Text color components (1 = gray, 3 = RGB, 4 = CMYK).
91    pub color: Vec<f32>,
92}
93
94/// A logical form field with everything an editor UI needs.
95#[derive(Debug, Clone, PartialEq)]
96pub struct FormFieldModel {
97    /// Fully-qualified field name — the handle for all get/set operations.
98    pub name: String,
99    /// Typed kind with kind-specific data.
100    pub kind: FormFieldKind,
101    /// Current value: text fields the text, buttons the on-state name or
102    /// `Off`, choice fields the selected export value (joined with `", "` for
103    /// multi-select list boxes — use `selected_values` for the array form).
104    pub value: Option<String>,
105    /// Selected export values for multi-select list boxes (`/Ff` bit 22 set).
106    /// `None` for all other field types. Empty vec means nothing selected.
107    pub selected_values: Option<Vec<String>>,
108    /// Default value (`/DV`).
109    pub default_value: Option<String>,
110    /// User-facing label (`/TU`, the accessibility/tooltip name).
111    pub tooltip: Option<String>,
112    /// ReadOnly flag (`/Ff` bit 1, inherited).
113    pub read_only: bool,
114    /// Required flag (`/Ff` bit 2, inherited).
115    pub required: bool,
116    /// `/MaxLen` for text fields (inherited).
117    pub max_len: Option<u32>,
118    /// Text alignment.
119    pub quadding: Quadding,
120    /// Resolved `/DA` font/size/color info.
121    pub da: DaInfo,
122    /// All widget annotations of this field (one per visual occurrence).
123    pub widgets: Vec<WidgetModel>,
124}
125
126/// Build the editor-facing model from a parsed field tree.
127///
128/// Logical fields are tree nodes that carry (or inherit) a field type and
129/// have no *named* children: a radio group whose kids are unnamed widgets is
130/// ONE logical field; a container with named children contributes its
131/// children, not itself.
132pub fn build_form_model(tree: &FieldTree) -> Vec<FormFieldModel> {
133    let mut out = Vec::new();
134    for id in tree.all_ids() {
135        if !is_logical_field(tree, id) {
136            continue;
137        }
138        if let Some(model) = field_model(tree, id) {
139            out.push(model);
140        }
141    }
142    out
143}
144
145/// A node is a logical field when it resolves to a field type and none of
146/// its children are named fields themselves.
147fn is_logical_field(tree: &FieldTree, id: FieldId) -> bool {
148    if tree.effective_field_type(id).is_none() {
149        return false;
150    }
151    let node = tree.get(id);
152    // Unnamed nodes are widgets of their parent, not fields.
153    if node.partial_name.is_empty() && node.parent.is_some() {
154        return false;
155    }
156    // A node with named children is a container.
157    !node
158        .children
159        .iter()
160        .any(|&c| !tree.get(c).partial_name.is_empty())
161}
162
163fn field_model(tree: &FieldTree, id: FieldId) -> Option<FormFieldModel> {
164    let node = tree.get(id);
165    let ft = tree.effective_field_type(id)?;
166    let flags = effective_flags_deep(tree, id);
167
168    let widgets = collect_widgets(tree, id);
169    let raw_value = tree.effective_value(id);
170    let selected_values = match &raw_value {
171        Some(FieldValue::StringArray(arr)) => Some(arr.clone()),
172        _ => None,
173    };
174    let value = raw_value.map(value_to_string);
175    let default_value = node.default_value.as_ref().map(value_to_string);
176
177    let kind = match ft {
178        FieldType::Text => FormFieldKind::Text {
179            multiline: flags.multiline(),
180            comb: flags.comb(),
181            password: flags.password(),
182        },
183        FieldType::Button => match button_kind(flags) {
184            ButtonKind::PushButton => FormFieldKind::PushButton,
185            ButtonKind::Checkbox => {
186                let on_state = widgets
187                    .iter()
188                    .find_map(|w| w.on_state.clone())
189                    .unwrap_or_else(|| "Yes".to_string());
190                let checked = value.as_deref().is_some_and(|v| v != "Off")
191                    || widgets
192                        .iter()
193                        .any(|w| w.appearance_state.as_deref().is_some_and(|s| s != "Off"));
194                FormFieldKind::Checkbox { on_state, checked }
195            }
196            ButtonKind::Radio => FormFieldKind::RadioGroup {
197                options: widgets
198                    .iter()
199                    .map(|w| w.on_state.clone().unwrap_or_default())
200                    .collect(),
201            },
202        },
203        FieldType::Choice => {
204            if flags.combo() {
205                FormFieldKind::ComboBox {
206                    editable: flags.edit(),
207                    options: node.options.clone(),
208                }
209            } else {
210                FormFieldKind::ListBox {
211                    multi_select: flags.multi_select(),
212                    options: node.options.clone(),
213                }
214            }
215        }
216        FieldType::Signature => FormFieldKind::Signature,
217    };
218
219    let da_str = tree.effective_da(id).unwrap_or("/Helv 0 Tf 0 g");
220    let da = parse_da(da_str);
221
222    Some(FormFieldModel {
223        name: tree.fully_qualified_name(id),
224        kind,
225        value,
226        selected_values,
227        default_value,
228        tooltip: node.alternate_name.clone(),
229        read_only: flags.read_only(),
230        required: flags.required(),
231        max_len: tree.effective_max_len(id),
232        quadding: tree.effective_quadding(id),
233        da: DaInfo {
234            font_name: da.font_name,
235            font_size: da.font_size,
236            color: da.color,
237        },
238        widgets,
239    })
240}
241
242/// Effective flags: the node's own, else the nearest ancestor's (the /Ff
243/// entry is inheritable per ISO 32000-1 Table 220).
244fn effective_flags_deep(tree: &FieldTree, id: FieldId) -> FieldFlags {
245    let mut cur = Some(id);
246    while let Some(cid) = cur {
247        let node = tree.get(cid);
248        if node.flags.bits() != 0 {
249            return node.flags;
250        }
251        cur = node.parent;
252    }
253    FieldFlags::empty()
254}
255
256fn collect_widgets(tree: &FieldTree, id: FieldId) -> Vec<WidgetModel> {
257    let node = tree.get(id);
258    let mut widgets = Vec::new();
259    if node.children.is_empty() {
260        if let Some(rect) = node.rect {
261            widgets.push(WidgetModel {
262                page_index: node.page_index,
263                rect,
264                on_state: node.on_state.clone(),
265                appearance_state: node.appearance_state.clone(),
266            });
267        }
268    } else {
269        for &kid in &node.children {
270            let k = tree.get(kid);
271            if let Some(rect) = k.rect {
272                widgets.push(WidgetModel {
273                    page_index: k.page_index,
274                    rect,
275                    on_state: k.on_state.clone(),
276                    appearance_state: k.appearance_state.clone(),
277                });
278            }
279        }
280    }
281    widgets
282}
283
284fn value_to_string(v: &FieldValue) -> String {
285    match v {
286        FieldValue::Text(s) => s.clone(),
287        FieldValue::StringArray(arr) => arr.join(", "),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::tree::FieldNode;
295
296    fn blank_node(name: &str) -> FieldNode {
297        FieldNode {
298            partial_name: name.into(),
299            alternate_name: None,
300            mapping_name: None,
301            field_type: None,
302            flags: FieldFlags::empty(),
303            value: None,
304            default_value: None,
305            default_appearance: None,
306            quadding: None,
307            max_len: None,
308            options: vec![],
309            top_index: None,
310            rect: None,
311            appearance_state: None,
312            on_state: None,
313            page_index: None,
314            parent: None,
315            children: vec![],
316            object_id: None,
317            has_actions: false,
318            mk: None,
319            border_style: None,
320        }
321    }
322
323    #[test]
324    fn radio_group_is_one_logical_field_with_widget_options() {
325        let mut tree = FieldTree::new();
326        let mut group = blank_node("kleur");
327        group.field_type = Some(FieldType::Button);
328        group.flags = FieldFlags::from_bits(1 << 15); // Radio
329        let gid = tree.alloc(group);
330        for (i, state) in ["Rood", "Blauw"].iter().enumerate() {
331            let mut w = blank_node("");
332            w.parent = Some(gid);
333            w.rect = Some([0.0, i as f32 * 20.0, 10.0, i as f32 * 20.0 + 10.0]);
334            w.on_state = Some(state.to_string());
335            w.appearance_state = Some("Off".into());
336            let wid = tree.alloc(w);
337            tree.get_mut(gid).children.push(wid);
338        }
339
340        let model = build_form_model(&tree);
341        assert_eq!(model.len(), 1, "group + 2 widgets must be ONE field");
342        let f = &model[0];
343        assert_eq!(f.name, "kleur");
344        assert_eq!(
345            f.kind,
346            FormFieldKind::RadioGroup {
347                options: vec!["Rood".into(), "Blauw".into()]
348            }
349        );
350        assert_eq!(f.widgets.len(), 2);
351        assert_eq!(f.widgets[1].on_state.as_deref(), Some("Blauw"));
352    }
353
354    #[test]
355    fn comb_and_multiline_flags_surface_in_kind() {
356        let mut tree = FieldTree::new();
357        let mut n = blank_node("bsn");
358        n.field_type = Some(FieldType::Text);
359        n.flags = FieldFlags::from_bits(1 << 24); // Comb
360        n.max_len = Some(9);
361        n.rect = Some([0.0, 0.0, 90.0, 12.0]);
362        tree.alloc(n);
363
364        let model = build_form_model(&tree);
365        assert_eq!(model.len(), 1);
366        assert_eq!(
367            model[0].kind,
368            FormFieldKind::Text {
369                multiline: false,
370                comb: true,
371                password: false
372            }
373        );
374        assert_eq!(model[0].max_len, Some(9));
375    }
376
377    #[test]
378    fn container_with_named_children_is_not_a_field() {
379        let mut tree = FieldTree::new();
380        let mut parent = blank_node("adres");
381        parent.field_type = Some(FieldType::Text);
382        let pid = tree.alloc(parent);
383        let mut kid = blank_node("straat");
384        kid.parent = Some(pid);
385        kid.rect = Some([0.0, 0.0, 100.0, 12.0]);
386        let kid_id = tree.alloc(kid);
387        tree.get_mut(pid).children.push(kid_id);
388
389        let model = build_form_model(&tree);
390        assert_eq!(model.len(), 1);
391        assert_eq!(model[0].name, "adres.straat");
392    }
393
394    #[test]
395    fn checkbox_reports_on_state_and_checked() {
396        let mut tree = FieldTree::new();
397        let mut n = blank_node("akkoord");
398        n.field_type = Some(FieldType::Button);
399        n.rect = Some([0.0, 0.0, 12.0, 12.0]);
400        n.on_state = Some("Akkoord".into());
401        n.appearance_state = Some("Akkoord".into());
402        n.value = Some(FieldValue::Text("Akkoord".into()));
403        tree.alloc(n);
404
405        let model = build_form_model(&tree);
406        assert_eq!(
407            model[0].kind,
408            FormFieldKind::Checkbox {
409                on_state: "Akkoord".into(),
410                checked: true
411            }
412        );
413    }
414}