Skip to main content

xfa_json/
schema.rs

1//! Schema extraction from FormTree.
2//!
3//! Generates a `FormSchema` describing the form's field structure,
4//! types, validation rules, and repetition constraints.
5
6// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12use crate::types::{FieldSchema, FieldType, FormSchema};
13use indexmap::IndexMap;
14use xfa_layout_engine::form::{FormNodeId, FormNodeType, FormTree};
15
16/// Extract a schema from a FormTree.
17///
18/// Returns a `FormSchema` with an entry for every field and draw node,
19/// including type hints, repetition rules, and scripts.
20pub fn export_schema(tree: &FormTree, root: FormNodeId) -> FormSchema {
21    let mut fields = IndexMap::new();
22    let node = tree.get(root);
23
24    match &node.node_type {
25        FormNodeType::Root | FormNodeType::PageSet | FormNodeType::PageArea { .. } => {
26            for &child_id in &node.children {
27                walk_schema(tree, child_id, "", false, &mut fields);
28            }
29        }
30        _ => {
31            walk_schema(tree, root, "", false, &mut fields);
32        }
33    }
34
35    FormSchema { fields }
36}
37
38/// Recursively walk the tree collecting schema entries.
39fn walk_schema(
40    tree: &FormTree,
41    node_id: FormNodeId,
42    parent_path: &str,
43    parent_repeatable: bool,
44    fields: &mut IndexMap<String, FieldSchema>,
45) {
46    let node = tree.get(node_id);
47    let path = if parent_path.is_empty() {
48        node.name.clone()
49    } else {
50        format!("{}.{}", parent_path, node.name)
51    };
52
53    let is_repeatable = parent_repeatable || node.occur.is_repeating();
54
55    match &node.node_type {
56        FormNodeType::Field { value } => {
57            let field_type = infer_field_type(value);
58            fields.insert(
59                path.clone(),
60                FieldSchema {
61                    som_path: path,
62                    field_type,
63                    required: node.occur.min > 0,
64                    repeatable: parent_repeatable,
65                    max_occurrences: node.occur.max,
66                    calculate: node.calculate.clone(),
67                    validate: node.validate.clone(),
68                },
69            );
70        }
71        FormNodeType::Draw(..) | FormNodeType::Image { .. } => {
72            fields.insert(
73                path.clone(),
74                FieldSchema {
75                    som_path: path,
76                    field_type: FieldType::Static,
77                    required: false,
78                    repeatable: parent_repeatable,
79                    max_occurrences: Some(1),
80                    calculate: None,
81                    validate: None,
82                },
83            );
84        }
85        // Area, ExclGroup, and SubformSet recurse like Subform for schema export.
86        FormNodeType::Subform
87        | FormNodeType::Area
88        | FormNodeType::ExclGroup
89        | FormNodeType::SubformSet
90        | FormNodeType::Root
91        | FormNodeType::PageSet
92        | FormNodeType::PageArea { .. } => {
93            for &child_id in &node.children {
94                walk_schema(tree, child_id, &path, is_repeatable, fields);
95            }
96        }
97    }
98}
99
100/// Infer the field type from the current value.
101fn infer_field_type(value: &str) -> FieldType {
102    let trimmed = value.trim();
103
104    if trimmed.is_empty() {
105        return FieldType::Text; // Unknown, default to text
106    }
107
108    match trimmed.to_ascii_lowercase().as_str() {
109        "true" | "false" | "0" | "1" => return FieldType::Boolean,
110        _ => {}
111    }
112
113    if trimmed.parse::<f64>().is_ok() {
114        return FieldType::Numeric;
115    }
116
117    FieldType::Text
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use xfa_layout_engine::form::{FormNode, Occur};
124    use xfa_layout_engine::text::FontMetrics;
125    use xfa_layout_engine::types::{BoxModel, LayoutStrategy};
126
127    fn make_field(
128        tree: &mut FormTree,
129        name: &str,
130        value: &str,
131        calculate: Option<&str>,
132        validate: Option<&str>,
133    ) -> FormNodeId {
134        tree.add_node(FormNode {
135            name: name.to_string(),
136            node_type: FormNodeType::Field {
137                value: value.to_string(),
138            },
139            box_model: BoxModel::default(),
140            layout: LayoutStrategy::Positioned,
141            children: vec![],
142            occur: Occur::once(),
143            font: FontMetrics::default(),
144            calculate: calculate.map(|s| s.to_string()),
145            validate: validate.map(|s| s.to_string()),
146            column_widths: vec![],
147            col_span: 1,
148        })
149    }
150
151    fn make_subform(
152        tree: &mut FormTree,
153        name: &str,
154        children: Vec<FormNodeId>,
155        occur: Occur,
156    ) -> FormNodeId {
157        tree.add_node(FormNode {
158            name: name.to_string(),
159            node_type: FormNodeType::Subform,
160            box_model: BoxModel::default(),
161            layout: LayoutStrategy::TopToBottom,
162            children,
163            occur,
164            font: FontMetrics::default(),
165            calculate: None,
166            validate: None,
167            column_widths: vec![],
168            col_span: 1,
169        })
170    }
171
172    fn make_root(tree: &mut FormTree, children: Vec<FormNodeId>) -> FormNodeId {
173        tree.add_node(FormNode {
174            name: "Root".to_string(),
175            node_type: FormNodeType::Root,
176            box_model: BoxModel::default(),
177            layout: LayoutStrategy::TopToBottom,
178            children,
179            occur: Occur::once(),
180            font: FontMetrics::default(),
181            calculate: None,
182            validate: None,
183            column_widths: vec![],
184            col_span: 1,
185        })
186    }
187
188    #[test]
189    fn schema_captures_field_types() {
190        let mut tree = FormTree::new();
191        let name = make_field(&mut tree, "Name", "Acme", None, None);
192        let amount = make_field(&mut tree, "Amount", "42.50", None, None);
193        let active = make_field(&mut tree, "Active", "true", None, None);
194
195        let form = make_subform(
196            &mut tree,
197            "form1",
198            vec![name, amount, active],
199            Occur::once(),
200        );
201        let root = make_root(&mut tree, vec![form]);
202
203        let schema = export_schema(&tree, root);
204
205        assert_eq!(
206            schema.fields.get("form1.Name").unwrap().field_type,
207            FieldType::Text
208        );
209        assert_eq!(
210            schema.fields.get("form1.Amount").unwrap().field_type,
211            FieldType::Numeric
212        );
213        assert_eq!(
214            schema.fields.get("form1.Active").unwrap().field_type,
215            FieldType::Boolean
216        );
217    }
218
219    #[test]
220    fn schema_includes_scripts() {
221        let mut tree = FormTree::new();
222        let tax = make_field(
223            &mut tree,
224            "Tax",
225            "0",
226            Some("Subtotal * 0.21"),
227            Some("Tax >= 0"),
228        );
229        let form = make_subform(&mut tree, "form1", vec![tax], Occur::once());
230        let root = make_root(&mut tree, vec![form]);
231
232        let schema = export_schema(&tree, root);
233        let tax_schema = schema.fields.get("form1.Tax").unwrap();
234
235        assert_eq!(tax_schema.calculate, Some("Subtotal * 0.21".to_string()));
236        assert_eq!(tax_schema.validate, Some("Tax >= 0".to_string()));
237    }
238
239    #[test]
240    fn schema_marks_repeatable_fields() {
241        let mut tree = FormTree::new();
242        let desc = make_field(&mut tree, "Description", "Item", None, None);
243        let item = make_subform(&mut tree, "Item", vec![desc], Occur::repeating(0, None, 1));
244        let form = make_subform(&mut tree, "form1", vec![item], Occur::once());
245        let root = make_root(&mut tree, vec![form]);
246
247        let schema = export_schema(&tree, root);
248        let desc_schema = schema.fields.get("form1.Item.Description").unwrap();
249
250        assert!(desc_schema.repeatable);
251    }
252
253    #[test]
254    fn schema_required_field() {
255        let mut tree = FormTree::new();
256        let req = make_field(&mut tree, "Required", "x", None, None);
257        let form = make_subform(&mut tree, "form1", vec![req], Occur::once());
258        let root = make_root(&mut tree, vec![form]);
259
260        let schema = export_schema(&tree, root);
261        assert!(schema.fields.get("form1.Required").unwrap().required);
262    }
263
264    #[test]
265    fn infer_field_type_works() {
266        assert_eq!(infer_field_type("hello"), FieldType::Text);
267        assert_eq!(infer_field_type("42"), FieldType::Numeric);
268        assert_eq!(infer_field_type("3.14"), FieldType::Numeric);
269        assert_eq!(infer_field_type("true"), FieldType::Boolean);
270        assert_eq!(infer_field_type("0"), FieldType::Boolean);
271        assert_eq!(infer_field_type(""), FieldType::Text);
272    }
273}