Skip to main content

pdf_xfa/
classify.rs

1//! XFA form type classification — static vs dynamic vs none.
2//!
3//! XFA Spec 3.3 §9.1 distinguishes two XFA form profiles:
4//!
5//! - **Static (XFAF)**: `baseProfile="interactiveForms"` on `<template>`.
6//!   Layout is fixed; form fields are baked in as AcroForm widgets.
7//! - **Dynamic**: full XFA grammar; layout is re-computed from data at render
8//!   time.  No `baseProfile` restriction.
9//!
10//! Use [`detect_xfa_type_from_packets`] when you already have [`XfaPackets`],
11//! or [`detect_xfa_type`] to parse raw PDF bytes end-to-end.
12//!
13//! # Example
14//! ```rust,ignore
15//! use pdf_xfa::classify::{detect_xfa_type, XfaType};
16//! match detect_xfa_type(&pdf_bytes) {
17//!     XfaType::Static  => println!("XFAF / static form"),
18//!     XfaType::Dynamic => println!("full dynamic XFA"),
19//!     XfaType::None    => println!("not an XFA PDF"),
20//! }
21//! ```
22
23// Copyright (c) 2026 Innovation Trigger B.V.
24//
25// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
26// the PDFluent Commercial Licence. See the LICENSE file in this repository --
27// that file travels with the copy you received, which a URL does not.
28
29use crate::extract::{extract_xfa_from_bytes, XfaPackets};
30
31/// Classification of an XFA form.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum XfaType {
34    /// Not an XFA PDF (no XFA content found).
35    None,
36    /// Static XFA form (`baseProfile="interactiveForms"`).
37    /// Layout is fixed; content is pre-rendered in the PDF page streams.
38    Static,
39    /// Dynamic XFA form — full XFA grammar, layout computed at runtime.
40    Dynamic,
41}
42
43impl std::fmt::Display for XfaType {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            XfaType::None => write!(f, "None"),
47            XfaType::Static => write!(f, "Static"),
48            XfaType::Dynamic => write!(f, "Dynamic"),
49        }
50    }
51}
52
53/// Detect XFA type from already-extracted [`XfaPackets`].
54///
55/// Returns [`XfaType::None`] when there are no packets and no full XML.
56pub fn detect_xfa_type_from_packets(packets: &XfaPackets) -> XfaType {
57    // No packets and no monolithic XML → not an XFA document.
58    if packets.packets.is_empty() && packets.full_xml.is_none() {
59        return XfaType::None;
60    }
61
62    // Look for baseProfile="interactiveForms" in the template packet first,
63    // then fall back to searching the full XDP XML blob.
64    let template_xml: Option<&str> = packets.template();
65    let full_xml: Option<&str> = packets.full_xml.as_deref();
66
67    let search_text: &str = template_xml.or(full_xml).unwrap_or("");
68
69    if search_text.contains(r#"baseProfile="interactiveForms""#) {
70        XfaType::Static
71    } else if !search_text.is_empty() || packets.packets.iter().any(|(name, _)| name == "template")
72    {
73        XfaType::Dynamic
74    } else {
75        // We have something (datasets-only, config-only, etc.) but no template.
76        // Treat as Dynamic — it is at minimum an XFA artefact.
77        XfaType::Dynamic
78    }
79}
80
81/// Detect XFA type from raw PDF bytes.
82///
83/// Parses and extracts XFA packets, then classifies the form.
84/// Returns [`XfaType::None`] when the PDF has no XFA content.
85pub fn detect_xfa_type(pdf_bytes: &[u8]) -> XfaType {
86    // extract_xfa_from_bytes requires Into<PdfData>; Vec<u8> satisfies that.
87    match extract_xfa_from_bytes(pdf_bytes.to_vec()) {
88        Ok(packets) => detect_xfa_type_from_packets(&packets),
89        Err(_) => XfaType::None,
90    }
91}
92
93// ─── unit tests ─────────────────────────────────────────────────────────────
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::extract::XfaPackets;
99
100    fn packets_from_xml(xml: &str) -> XfaPackets {
101        // Use the internal parse helper via extract module's public surface.
102        // We build XfaPackets by hand to keep tests self-contained.
103        let mut p = XfaPackets {
104            full_xml: Some(xml.to_string()),
105            ..Default::default()
106        };
107        // Manually push the template packet so get_packet("template") works.
108        if xml.contains("<template") {
109            let start = xml.find("<template").unwrap();
110            // Find the closing tag — handle both qualified and plain versions.
111            let end = xml
112                .find("</template>")
113                .map(|i| i + "</template>".len())
114                .or({
115                    // self-closing or other variant — take to end of xml as fallback
116                    Some(xml.len())
117                })
118                .unwrap();
119            p.packets
120                .push(("template".to_string(), xml[start..end].to_string()));
121        }
122        p
123    }
124
125    #[test]
126    fn empty_packets_returns_none() {
127        let p = XfaPackets::default();
128        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::None);
129    }
130
131    #[test]
132    fn static_form_detected_via_base_profile() {
133        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/" baseProfile="interactiveForms"><subform name="root"/></template></xdp:xdp>"#;
134        let p = packets_from_xml(xml);
135        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Static);
136    }
137
138    #[test]
139    fn dynamic_form_detected_when_no_base_profile() {
140        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"><occur min="0" max="-1"/></subform></template></xdp:xdp>"#;
141        let p = packets_from_xml(xml);
142        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Dynamic);
143    }
144
145    #[test]
146    fn xfa_type_none_on_empty_pdf_bytes() {
147        // Empty slice cannot be a valid PDF → XfaType::None.
148        assert_eq!(detect_xfa_type(&[]), XfaType::None);
149    }
150
151    #[test]
152    fn xfa_type_display() {
153        assert_eq!(XfaType::None.to_string(), "None");
154        assert_eq!(XfaType::Static.to_string(), "Static");
155        assert_eq!(XfaType::Dynamic.to_string(), "Dynamic");
156    }
157
158    #[test]
159    fn packets_with_only_full_xml_static() {
160        // Simulate a monolithic XDP stream (full_xml set, packets empty).
161        let xml = r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template baseProfile="interactiveForms"><subform/></template></xdp:xdp>"#;
162        let p = XfaPackets {
163            full_xml: Some(xml.to_string()),
164            ..Default::default()
165        };
166        // No individual packets — detect from full_xml fallback.
167        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Static);
168    }
169
170    #[test]
171    fn datasets_only_packet_treated_as_dynamic() {
172        let mut p = XfaPackets::default();
173        p.packets
174            .push(("datasets".to_string(), "<xfa:datasets/>".to_string()));
175        // datasets-only is still an XFA artefact — Dynamic.
176        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Dynamic);
177    }
178}