Skip to main content

pdf_xfa/
extract.rs

1//! XFA packet extraction from PDF via pdf-syntax.
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.
8use crate::error::{Result, XfaError};
9use pdf_syntax::object::dict::keys::{ACRO_FORM, XFA};
10use pdf_syntax::object::{Array, Dict, Object, Stream};
11use pdf_syntax::{Filter, Pdf};
12
13/// Return `true` when a stream's filters indicate that it is an image or other
14/// binary blob that cannot contain XFA XML text.
15///
16/// XFA `<xdp:xdp>` packets are stored as plain XML (optionally `/FlateDecode`
17/// compressed). They are never wrapped with image codecs such as JPEG, JPEG
18/// 2000, JBIG2 or CCITT fax. Detecting these filters before invoking
19/// [`Stream::decoded`] avoids expensive image decoding during the XFA scan
20/// fallback (see PERF2-01: JPEG 2000 decode accounted for ~45% of wall-time
21/// on `edd_DE44.pdf`).
22fn is_image_only_stream(stream: &Stream<'_>) -> bool {
23    stream.filters().iter().any(|f| {
24        matches!(
25            f,
26            Filter::JpxDecode | Filter::DctDecode | Filter::Jbig2Decode | Filter::CcittFaxDecode
27        )
28    })
29}
30/// XfaPackets.
31
32#[derive(Debug, Clone, Default)]
33pub struct XfaPackets {
34    /// full_xml.
35    pub full_xml: Option<String>,
36    /// packets.
37    pub packets: Vec<(String, String)>,
38}
39
40impl XfaPackets {
41    /// get_packet.
42    pub fn get_packet(&self, name: &str) -> Option<&str> {
43        self.packets
44            .iter()
45            .find(|(n, _)| n == name)
46            .map(|(_, v)| v.as_str())
47    }
48    /// template.
49    pub fn template(&self) -> Option<&str> {
50        self.get_packet("template")
51    }
52    /// datasets.
53    pub fn datasets(&self) -> Option<&str> {
54        // When multiple "datasets" packets exist (e.g. from incremental saves),
55        // prefer the largest one — the small/empty one is the original blank form
56        // and the larger one contains the filled data.
57        self.packets
58            .iter()
59            .filter(|(n, _)| n == "datasets")
60            .max_by_key(|(_, v)| v.len())
61            .map(|(_, v)| v.as_str())
62    }
63    /// config.
64    pub fn config(&self) -> Option<&str> {
65        self.get_packet("config")
66    }
67    /// locale_set.
68    pub fn locale_set(&self) -> Option<&str> {
69        self.get_packet("localeSet")
70    }
71}
72/// Outcome of probing the catalog/AcroForm structure for an XFA entry.
73///
74/// Distinguishes "AcroForm is readable but carries no XFA" from "the AcroForm
75/// structure itself was unreadable". This lets [`extract_xfa`] skip the
76/// expensive whole-document [`scan_for_xfa`] fallback when we can prove from
77/// the spec-compliant location (catalog → /AcroForm → /XFA, XFA 3.3 §3) that
78/// no XFA is present.
79///
80/// QF1-A (L-01 long-form hotspot): documents like California EDD `DE 44` carry
81/// `/AcroForm` (widget-based interactive form) without `/XFA`. The byte
82/// pre-check in `flatten_xfa_to_pdf_internal` (looking for `/AcroForm` or
83/// `xdp:xdp`) is permissive on purpose, so it falls through to
84/// `extract_xfa_from_bytes`, which previously had to walk every compressed
85/// object stream looking for `<xdp:xdp` before giving up (~775 ms / ~56 % of
86/// wall time on `edd_DE44.pdf`). With this probe we early-return
87/// [`XfaError::PacketNotFound`] for the pure-AcroForm case, leaving the
88/// `scan_for_xfa` fallback only for genuinely broken catalog/AcroForm
89/// structures.
90#[derive(Debug)]
91enum AcroformProbe {
92    /// AcroForm exists, has /XFA, and packets were successfully extracted.
93    XfaFound(XfaPackets),
94    /// Catalog read OK; no AcroForm dictionary in catalog. Per XFA 3.3 §3 the
95    /// XFA packets live inside the AcroForm dictionary, so no AcroForm =
96    /// no XFA. (A document MAY in principle carry an orphaned XFA stream
97    /// outside the spec layout; that pathology still hits the fallback via
98    /// [`AcroformProbe::Unreadable`] when the catalog itself fails.)
99    NoAcroform,
100    /// AcroForm exists and is readable, but the dictionary has no /XFA entry.
101    /// Spec-compliant pure-AcroForm form — `scan_for_xfa` would just burn
102    /// time walking every object stream and return PacketNotFound anyway.
103    AcroformNoXfa,
104    /// The catalog or AcroForm dictionary could not be read at all (truncated
105    /// PDF, repaired xref still broken, etc.). Fall through to the
106    /// best-effort whole-document scan.
107    Unreadable,
108}
109
110/// extract_xfa.
111pub fn extract_xfa(pdf: &Pdf) -> Result<XfaPackets> {
112    match probe_acroform_for_xfa(pdf) {
113        AcroformProbe::XfaFound(mut p) => {
114            // If the datasets packet is empty/tiny (common with incremental saves
115            // where Adobe Reader writes a new datasets object but doesn't update
116            // the XFA array reference), scan all objects for a larger one.
117            let current_ds_len = p.datasets().map(|s| s.len()).unwrap_or(0);
118            if current_ds_len < 200 {
119                if let Some(better_ds) = scan_for_datasets(pdf, current_ds_len) {
120                    p.packets.push(("datasets".to_string(), better_ds));
121                }
122            }
123            Ok(p)
124        }
125        AcroformProbe::NoAcroform | AcroformProbe::AcroformNoXfa => {
126            // QF1-A: skip the expensive whole-document object scan. Per XFA 3.3 §3
127            // the XFA packets live inside the AcroForm dictionary, so a
128            // spec-compliant document without /AcroForm /XFA cannot carry XFA.
129            // The caller (`flatten_xfa_to_pdf_internal`) will route to
130            // `static_fallback`, preserving correctness while eliminating
131            // ~775 ms of wasted scan time on pure-AcroForm long-form docs like
132            // `edd_DE44.pdf`.
133            Err(XfaError::PacketNotFound("no XFA content found".to_string()))
134        }
135        AcroformProbe::Unreadable => {
136            // Best-effort fallback: the catalog/AcroForm structure could not be
137            // read, but the byte-level pre-check found XFA-like markers. Scan
138            // every non-image stream for a `<xdp:xdp` packet.
139            scan_for_xfa(pdf)
140        }
141    }
142}
143
144/// Probe the catalog/AcroForm structure for the presence of `/XFA`.
145///
146/// Returns the strongest outcome we can determine from a small number of
147/// xref hits, so [`extract_xfa`] can avoid the expensive whole-document
148/// fallback when we have positive evidence that no XFA can be present.
149fn probe_acroform_for_xfa(pdf: &Pdf) -> AcroformProbe {
150    let xref = pdf.xref();
151    let Some(catalog): Option<Dict<'_>> = xref.get(xref.root_id()) else {
152        return AcroformProbe::Unreadable;
153    };
154    let Some(acroform): Option<Dict<'_>> = catalog.get(ACRO_FORM) else {
155        return AcroformProbe::NoAcroform;
156    };
157    if let Some(stream) = acroform.get::<Stream<'_>>(XFA) {
158        if let Some(decoded) = decode_stream(&stream) {
159            return AcroformProbe::XfaFound(parse_xfa_xml(&decoded));
160        }
161        // /XFA stream object existed but could not be decoded — treat as
162        // unreadable so the scan fallback gets a chance.
163        return AcroformProbe::Unreadable;
164    }
165    if let Some(array) = acroform.get::<Array<'_>>(XFA) {
166        return AcroformProbe::XfaFound(extract_from_array(&array));
167    }
168    AcroformProbe::AcroformNoXfa
169}
170
171/// True when `xml` IS a datasets packet: its root element is `datasets`.
172/// A consolidated XDP merely *contains* `<xfa:datasets`; taking it for the
173/// packet makes the writeback embed the whole XDP inside itself.
174fn is_datasets_packet(xml: &str) -> bool {
175    let mut rest = xml.trim_start_matches('\u{feff}').trim_start();
176    // Skip the XML declaration, processing instructions and comments.
177    loop {
178        if let Some(r) = rest.strip_prefix("<?") {
179            match r.find("?>") {
180                Some(end) => rest = r[end + 2..].trim_start(),
181                None => return false,
182            }
183        } else if let Some(r) = rest.strip_prefix("<!--") {
184            match r.find("-->") {
185                Some(end) => rest = r[end + 3..].trim_start(),
186                None => return false,
187            }
188        } else {
189            break;
190        }
191    }
192    let Some(tag) = rest.strip_prefix('<') else {
193        return false;
194    };
195    let name_end = tag
196        .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
197        .unwrap_or(tag.len());
198    let qname = &tag[..name_end];
199    qname.rsplit(':').next() == Some("datasets")
200}
201
202/// Scan all PDF stream objects for a datasets packet larger than `min_len`.
203/// Returns the largest found, if any.
204fn scan_for_datasets(pdf: &Pdf, min_len: usize) -> Option<String> {
205    let mut best: Option<String> = None;
206    for obj in pdf.objects() {
207        if let Object::Stream(s) = obj {
208            // Skip image streams — they never contain XFA datasets XML.
209            if is_image_only_stream(&s) {
210                continue;
211            }
212            if let Some(d) = decode_stream(&s) {
213                if d.len() > min_len
214                    && is_datasets_packet(&d)
215                    && best.as_ref().is_none_or(|b| d.len() > b.len())
216                {
217                    best = Some(d);
218                }
219            }
220        }
221    }
222    best
223}
224/// extract_xfa_from_bytes.
225pub fn extract_xfa_from_bytes(data: impl Into<pdf_syntax::PdfData>) -> Result<XfaPackets> {
226    let pdf = Pdf::new(data).map_err(|e| XfaError::LoadFailed(format!("{e:?}")))?;
227    extract_xfa(&pdf)
228}
229/// extract_xfa_from_acroform.
230pub fn extract_xfa_from_acroform(pdf: &Pdf) -> Option<XfaPackets> {
231    let xref = pdf.xref();
232    let catalog: Dict<'_> = xref.get(xref.root_id())?;
233    let acroform: Dict<'_> = catalog.get(ACRO_FORM)?;
234    if let Some(stream) = acroform.get::<Stream<'_>>(XFA) {
235        return Some(parse_xfa_xml(&decode_stream(&stream)?));
236    }
237    if let Some(array) = acroform.get::<Array<'_>>(XFA) {
238        return Some(extract_from_array(&array));
239    }
240    None
241}
242
243fn extract_from_array(array: &Array<'_>) -> XfaPackets {
244    let mut packets = XfaPackets::default();
245    let items: Vec<Object<'_>> = array.iter::<Object<'_>>().collect();
246    let mut i = 0;
247    while i + 1 < items.len() {
248        let name = match &items[i] {
249            Object::String(s) => std::string::String::from_utf8_lossy(s.as_bytes()).to_string(),
250            Object::Name(n) => std::string::String::from_utf8_lossy(n.as_ref()).to_string(),
251            _ => {
252                i += 1;
253                continue;
254            }
255        };
256        if let Some(c) = match &items[i + 1] {
257            Object::Stream(s) => decode_stream(s),
258            Object::String(s) => {
259                Some(std::string::String::from_utf8_lossy(s.as_bytes()).to_string())
260            }
261            _ => None,
262        } {
263            packets.packets.push((name, c));
264        }
265        i += 2;
266    }
267    packets
268}
269
270fn scan_for_xfa(pdf: &Pdf) -> Result<XfaPackets> {
271    // Cap the number of streams we decompress to avoid multi-second stalls on
272    // large non-XFA PDFs. XFA XDP streams are typically among the first few
273    // hundred objects. If we haven't found one after 2000 streams, give up.
274    //
275    // PERF (PERF2-02): skip image streams (`/JPXDecode`, `/DCTDecode`,
276    // `/JBIG2Decode`, `/CCITTFaxDecode`) entirely. XFA packets are XML text
277    // wrapped at most by `/FlateDecode`; they never use image codecs. Decoding
278    // a JPEG 2000 image costs hundreds of milliseconds and dominated the
279    // wall-time on `edd_DE44.pdf` (PERF2-01 hotspot report).
280    let mut streams_checked = 0u32;
281    for obj in pdf.objects() {
282        if let Object::Stream(s) = obj {
283            if is_image_only_stream(&s) {
284                continue;
285            }
286            streams_checked += 1;
287            if streams_checked > 2000 {
288                break;
289            }
290            if let Some(d) = decode_stream(&s) {
291                if d.contains("<xdp:xdp") {
292                    return Ok(parse_xfa_xml(&d));
293                }
294            }
295        }
296    }
297    Err(XfaError::PacketNotFound("no XFA content found".to_string()))
298}
299
300fn decode_stream(stream: &Stream<'_>) -> Option<String> {
301    std::string::String::from_utf8(stream.decoded().ok()?).ok()
302}
303
304fn parse_xfa_xml(xml: &str) -> XfaPackets {
305    let mut packets = XfaPackets {
306        full_xml: Some(xml.to_string()),
307        packets: Vec::new(),
308    };
309    let t = xml.trim();
310    let c = t.find("?>").map(|p| &t[p + 2..]).unwrap_or(t).trim();
311    let inner = match c.find('>') {
312        Some(s) => {
313            let rest = &c[s + 1..];
314            rest.rfind("</xdp:xdp>")
315                .map(|e| &rest[..e])
316                .or_else(|| rest.rfind("</xdp>").map(|e| &rest[..e]))
317                .unwrap_or(rest)
318        }
319        None => return packets,
320    };
321    let mut pos = 0;
322    let bytes = inner.as_bytes();
323    while pos < bytes.len() {
324        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
325            pos += 1;
326        }
327        if pos >= bytes.len() {
328            break;
329        }
330        if bytes[pos] != b'<' {
331            pos += 1;
332            continue;
333        }
334        if inner[pos..].starts_with("<!--") {
335            if let Some(e) = inner[pos..].find("-->") {
336                pos += e + 3;
337                continue;
338            }
339        }
340        if inner[pos..].starts_with("<?") {
341            if let Some(e) = inner[pos..].find("?>") {
342                pos += e + 2;
343                continue;
344            }
345        }
346        let ts = pos;
347        pos += 1;
348        let ns = pos;
349        while pos < bytes.len() && bytes[pos] != b'>' && bytes[pos] != b' ' && bytes[pos] != b'/' {
350            pos += 1;
351        }
352        let ft = &inner[ns..pos];
353        let pn = ft.split(':').next_back().unwrap_or(ft);
354        let ct = format!("</{ft}>");
355        let at = format!("</xfa:{pn}>");
356        if let Some(cp) = inner[ts..].find(ct.as_str()) {
357            let ee = ts + cp + ct.len();
358            packets
359                .packets
360                .push((pn.to_string(), inner[ts..ee].to_string()));
361            pos = ee;
362        } else if let Some(cp) = inner[ts..].find(at.as_str()) {
363            let ee = ts + cp + at.len();
364            packets
365                .packets
366                .push((pn.to_string(), inner[ts..ee].to_string()));
367            pos = ee;
368        } else {
369            while pos < bytes.len() && bytes[pos] != b'>' {
370                pos += 1;
371            }
372            pos += 1;
373        }
374    }
375    packets
376}
377
378// ─── Packet validation ───────────────────────────────────────────────────────
379
380/// Summary of what was found (or missing) in a set of [`XfaPackets`].
381///
382/// Returned by [`validate_xfa_packets`].  Intended for diagnostics, logging,
383/// and deciding how to handle unusual or incomplete XFA documents.
384#[derive(Debug, Clone, Default)]
385pub struct PacketValidation {
386    /// `true` when a `template` packet is present.
387    pub has_template: bool,
388    /// `true` when at least one `datasets` packet is present.
389    pub has_datasets: bool,
390    /// `true` when a `config` packet is present.
391    pub has_config: bool,
392    /// Byte length of the template packet (0 if absent).
393    pub template_bytes: usize,
394    /// Byte length of the largest datasets packet (0 if absent).
395    pub datasets_bytes: usize,
396    /// Names of all packets in document order.
397    pub packet_names: Vec<String>,
398    /// Human-readable warnings about missing or suspicious content.
399    pub warnings: Vec<String>,
400}
401
402/// Validate the contents of [`XfaPackets`] and return a [`PacketValidation`].
403///
404/// This function never panics and never fails — it always returns a result,
405/// even for empty or degenerate packet sets.
406pub fn validate_xfa_packets(packets: &XfaPackets) -> PacketValidation {
407    let has_template = packets.template().is_some();
408    let has_datasets = packets.datasets().is_some();
409    let has_config = packets.config().is_some();
410
411    let template_bytes = packets.template().map(|s| s.len()).unwrap_or(0);
412    let datasets_bytes = packets.datasets().map(|s| s.len()).unwrap_or(0);
413    let packet_names = packets.packets.iter().map(|(n, _)| n.clone()).collect();
414
415    let mut warnings = Vec::new();
416
417    if !has_template {
418        warnings.push("No template packet found".to_string());
419    } else if template_bytes < 100 {
420        warnings.push(format!(
421            "Template packet is empty (< 100 bytes) — only {template_bytes} bytes"
422        ));
423    }
424
425    if !has_datasets {
426        warnings.push("No datasets packet".to_string());
427    } else if datasets_bytes < 50 {
428        warnings.push(format!(
429            "Datasets packet is suspiciously small (< 50 bytes) — only {datasets_bytes} bytes"
430        ));
431    }
432
433    PacketValidation {
434        has_template,
435        has_datasets,
436        has_config,
437        template_bytes,
438        datasets_bytes,
439        packet_names,
440        warnings,
441    }
442}
443// ─── Embedded font extraction ────────────────────────────────────────────────
444
445/// Extract embedded font programs from a PDF parsed with pdf-syntax.
446///
447/// Returns a list of `(name, raw_font_bytes)` pairs for every `FontDescriptor`
448/// object that carries a `FontFile`, `FontFile2`, or `FontFile3` stream.
449///
450/// # Relationship to the flatten pipeline
451///
452/// The flattening pipeline (`crate::flatten`) uses a separate lopdf-based
453/// variant (internal, `#[doc(hidden)]`) that additionally captures `/Widths`
454/// arrays and encoding metadata needed for text measurement. That variant
455/// returns [`crate::font_bridge::EmbeddedFontData`] structs and is not part
456/// of the public extraction API.
457///
458/// Use this function when you only need the raw font bytes for inspection,
459/// subsetting, or external embedding outside the flatten pipeline.
460pub fn extract_embedded_fonts(pdf: &Pdf) -> Vec<(String, Vec<u8>)> {
461    use pdf_syntax::object::dict::keys::{FONT_FILE, FONT_FILE2, FONT_FILE3, FONT_NAME, TYPE};
462    use pdf_syntax::object::Name;
463    let mut fonts = Vec::new();
464    for obj in pdf.objects() {
465        let dict = match &obj {
466            Object::Dict(d) => d.clone(),
467            Object::Stream(s) => s.dict().clone(),
468            _ => continue,
469        };
470        if dict
471            .get::<Name>(TYPE)
472            .is_none_or(|n| n.as_ref() != b"FontDescriptor")
473        {
474            continue;
475        }
476        let name = dict
477            .get::<Name>(FONT_NAME)
478            .map(|n| std::string::String::from_utf8_lossy(n.as_ref()).to_string())
479            .unwrap_or_default();
480        for key in [FONT_FILE2, FONT_FILE, FONT_FILE3] {
481            if let Some(s) = dict.get::<Stream<'_>>(key) {
482                if let Ok(d) = s.decoded() {
483                    if !d.is_empty() {
484                        fonts.push((name.clone(), d));
485                        break;
486                    }
487                }
488            }
489        }
490    }
491    fonts
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    #[test]
498    fn parse_xfa_packets() {
499        let xml = r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="f1"><field name="T1"/></subform></template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><f1><T1>Hi</T1></f1></xfa:data></xfa:datasets></xdp:xdp>"#;
500        let p = parse_xfa_xml(xml);
501        assert_eq!(p.packets.len(), 2);
502        assert!(p.template().is_some());
503        assert!(p.datasets().is_some());
504    }
505    #[test]
506    fn empty_xfa() {
507        let p = parse_xfa_xml(r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"></xdp:xdp>"#);
508        assert_eq!(p.packets.len(), 0);
509    }
510
511    #[test]
512    fn get_packet_missing_returns_none() {
513        let p = parse_xfa_xml(r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"></xdp:xdp>"#);
514        assert!(p.get_packet("template").is_none());
515        assert!(p.get_packet("nonexistent").is_none());
516        assert!(p.config().is_none());
517        assert!(p.locale_set().is_none());
518    }
519
520    #[test]
521    fn full_xml_preserved() {
522        // full_xml should always capture the entire input string.
523        let xml =
524            r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"></xdp:xdp>"#;
525        let p = parse_xfa_xml(xml);
526        let stored = p.full_xml.as_deref().unwrap_or("");
527        assert!(stored.contains("xdp:xdp"));
528    }
529
530    #[test]
531    fn config_packet_parsed() {
532        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><config xmlns="http://www.xfa.org/schema/xci/3.1/"><present><xdp><packets>*</packets></xdp></present></config></xdp:xdp>"#;
533        let p = parse_xfa_xml(xml);
534        assert_eq!(p.packets.len(), 1);
535        assert!(p.config().is_some());
536        assert!(p.template().is_none());
537    }
538
539    #[test]
540    fn multiple_packets_order_preserved() {
541        // template must come before datasets — order matches the XDP source order.
542        let xml = r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"/></template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data/></xfa:datasets></xdp:xdp>"#;
543        let p = parse_xfa_xml(xml);
544        assert_eq!(p.packets.len(), 2);
545        assert_eq!(p.packets[0].0, "template");
546        assert_eq!(p.packets[1].0, "datasets");
547        assert!(p.template().is_some());
548        assert!(p.datasets().is_some());
549    }
550
551    // ── PacketValidation tests (issue #1085) ──────────────────────────────
552
553    #[test]
554    fn validate_complete_packets_no_warnings() {
555        let xml = r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"><field name="firstName" xmlns:ui="http://www.xfa.org/schema/xfa-template/3.3/"><ui><textEdit/></ui></field></subform></template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><root><firstName>Alice</firstName></root></xfa:data></xfa:datasets></xdp:xdp>"#;
556        let p = parse_xfa_xml(xml);
557        let v = validate_xfa_packets(&p);
558        assert!(v.has_template);
559        assert!(v.has_datasets);
560        assert!(v.template_bytes > 0);
561        assert!(v.datasets_bytes > 0);
562        assert!(
563            v.warnings.is_empty(),
564            "expected no warnings, got: {:?}",
565            v.warnings
566        );
567    }
568
569    #[test]
570    fn validate_missing_template_produces_warning() {
571        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data/></xfa:datasets></xdp:xdp>"#;
572        let p = parse_xfa_xml(xml);
573        let v = validate_xfa_packets(&p);
574        assert!(!v.has_template);
575        assert!(v
576            .warnings
577            .iter()
578            .any(|w| w.contains("No template packet found")));
579    }
580
581    #[test]
582    fn validate_missing_datasets_produces_warning() {
583        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"><field name="x"/><field name="y"/><field name="z"/><field name="w"/></subform></template></xdp:xdp>"#;
584        let p = parse_xfa_xml(xml);
585        let v = validate_xfa_packets(&p);
586        assert!(!v.has_datasets);
587        assert!(v.warnings.iter().any(|w| w.contains("No datasets packet")));
588    }
589
590    #[test]
591    fn validate_tiny_template_produces_warning() {
592        let mut p = XfaPackets::default();
593        p.packets.push(("template".to_string(), "<t/>".to_string()));
594        p.packets.push((
595            "datasets".to_string(),
596            "<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data/></xfa:datasets>".to_string(),
597        ));
598        let v = validate_xfa_packets(&p);
599        assert!(v.warnings.iter().any(|w| w.contains("< 100 bytes")));
600    }
601
602    #[test]
603    fn validate_tiny_datasets_produces_warning() {
604        let mut p = XfaPackets::default();
605        // Give a substantial template so that warning comes from datasets only.
606        p.packets.push((
607            "template".to_string(),
608            "<template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"root\"><field name=\"a\"/><field name=\"b\"/><field name=\"c\"/></subform></template>".to_string(),
609        ));
610        p.packets
611            .push(("datasets".to_string(), "<ds/>".to_string()));
612        let v = validate_xfa_packets(&p);
613        assert!(v.warnings.iter().any(|w| w.contains("< 50 bytes")));
614    }
615
616    #[test]
617    fn validate_packet_names_list() {
618        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><config xmlns="http://www.xfa.org/schema/xci/3.1/"><present/></config><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"><field name="f1"/><field name="f2"/><field name="f3"/></subform></template></xdp:xdp>"#;
619        let p = parse_xfa_xml(xml);
620        let v = validate_xfa_packets(&p);
621        assert!(v.packet_names.contains(&"config".to_string()));
622        assert!(v.packet_names.contains(&"template".to_string()));
623        assert!(v.has_config);
624    }
625
626    // ── XFA corpus tests (issue #1086) ────────────────────────────────────
627    // Ten synthetic tests covering representative XFA document patterns.
628    // Each test uses small in-memory XML strings — no real PDFs required.
629
630    /// 1. Static XFA form detection via baseProfile.
631    #[test]
632    fn corpus_01_static_xfa_form_detection() {
633        use crate::classify::{detect_xfa_type_from_packets, XfaType};
634        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="Page1"><field name="LastName"/><field name="FirstName"/></subform></template></xdp:xdp>"#;
635        let p = parse_xfa_xml(xml);
636        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Static);
637    }
638
639    /// 2. Dynamic XFA form detection (no baseProfile constraint).
640    #[test]
641    fn corpus_02_dynamic_xfa_form_detection() {
642        use crate::classify::{detect_xfa_type_from_packets, XfaType};
643        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"/><field name="item"/></subform></template></xdp:xdp>"#;
644        let p = parse_xfa_xml(xml);
645        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Dynamic);
646    }
647
648    /// 3. XFA with multiple packets (template + datasets + config).
649    #[test]
650    fn corpus_03_multiple_packets() {
651        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><config xmlns="http://www.xfa.org/schema/xci/3.1/"><present><xdp><packets>*</packets></xdp></present></config><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"/></template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data/></xfa:datasets></xdp:xdp>"#;
652        let p = parse_xfa_xml(xml);
653        assert_eq!(p.packets.len(), 3, "should have config, template, datasets");
654        assert!(p.config().is_some());
655        assert!(p.template().is_some());
656        assert!(p.datasets().is_some());
657    }
658
659    /// 4. XFA with no datasets (template-only — blank form, no data bound).
660    #[test]
661    fn corpus_04_template_only_no_datasets() {
662        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"><field name="LastName"/><field name="FirstName"/><field name="DOB"/></subform></template></xdp:xdp>"#;
663        let p = parse_xfa_xml(xml);
664        assert!(p.template().is_some());
665        assert!(p.datasets().is_none());
666        let v = validate_xfa_packets(&p);
667        assert!(!v.has_datasets);
668        assert!(v.warnings.iter().any(|w| w.contains("No datasets")));
669    }
670
671    /// 5. XFA with binary-like image data embedded in datasets (base64 blob).
672    #[test]
673    fn corpus_05_xfa_with_image_data_in_datasets() {
674        // Simulate datasets containing a base64-encoded image field.
675        let b64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
676        let xml = format!(
677            r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"><field name="photo"><ui><imageEdit/></ui></field></subform></template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><root><photo contentType="image/png" href="">{b64_image}</photo></root></xfa:data></xfa:datasets></xdp:xdp>"#
678        );
679        let p = parse_xfa_xml(&xml);
680        assert!(p.template().is_some());
681        assert!(p.datasets().is_some());
682        let ds = p.datasets().unwrap();
683        assert!(ds.contains(b64_image), "datasets should contain image data");
684    }
685
686    /// 6. Non-XFA PDF (empty bytes) returns XfaType::None.
687    #[test]
688    fn corpus_06_non_xfa_pdf_returns_none() {
689        use crate::classify::{detect_xfa_type, XfaType};
690        // A plain PDF header with no AcroForm/XFA.
691        let not_xfa: &[u8] = b"%PDF-1.4\n%%EOF";
692        assert_eq!(detect_xfa_type(not_xfa), XfaType::None);
693    }
694
695    /// 7. XFA with config packet — config is correctly parsed and accessible.
696    #[test]
697    fn corpus_07_xfa_with_config_packet() {
698        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><config xmlns="http://www.xfa.org/schema/xci/3.1/"><present><xdp><packets>*</packets></xdp></present><pdf><version>1.6</version></pdf></config><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"/></template></xdp:xdp>"#;
699        let p = parse_xfa_xml(xml);
700        assert!(p.config().is_some());
701        let cfg = p.config().unwrap();
702        assert!(cfg.contains("packets"));
703        let v = validate_xfa_packets(&p);
704        assert!(v.has_config);
705    }
706
707    /// 8. Empty datasets packet (incremental save pattern — original blank form).
708    #[test]
709    fn corpus_08_empty_datasets_incremental_save_pattern() {
710        // Two datasets entries: original blank (small) and filled (larger).
711        let mut p = XfaPackets::default();
712        p.packets.push((
713            "template".to_string(),
714            "<template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"root\"><field name=\"qty\"/><field name=\"price\"/><field name=\"total\"/></subform></template>".to_string(),
715        ));
716        // Blank (incremental save artefact — very small):
717        p.packets.push((
718            "datasets".to_string(),
719            "<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"/>".to_string(),
720        ));
721        // Filled (the real data):
722        p.packets.push(("datasets".to_string(), "<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><root><qty>3</qty><price>9.99</price><total>29.97</total></root></xfa:data></xfa:datasets>".to_string()));
723        // datasets() must return the LARGEST entry.
724        let ds = p.datasets().expect("datasets should exist");
725        assert!(
726            ds.contains("29.97"),
727            "should return the larger/filled datasets"
728        );
729    }
730
731    /// 9. Large template with many fields — validation should have no warnings.
732    #[test]
733    fn corpus_09_large_template_many_fields() {
734        // Build a template with 20 fields to ensure validation handles size correctly.
735        let fields: String = (1..=20)
736            .map(|i| format!("<field name=\"field{i}\"><ui><textEdit/></ui></field>"))
737            .collect();
738        let xml = format!(
739            r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root">{fields}</subform></template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><root>{}</root></xfa:data></xfa:datasets></xdp:xdp>"#,
740            (1..=20)
741                .map(|i| format!("<field{i}>val{i}</field{i}>"))
742                .collect::<String>()
743        );
744        let p = parse_xfa_xml(&xml);
745        let v = validate_xfa_packets(&p);
746        assert!(v.has_template);
747        assert!(v.has_datasets);
748        assert!(
749            v.template_bytes >= 100,
750            "large template should exceed 100 bytes"
751        );
752        assert!(
753            v.warnings.is_empty(),
754            "no warnings expected: {:?}",
755            v.warnings
756        );
757    }
758
759    /// 10. XFA with localeSet packet — localeSet is correctly accessible.
760    #[test]
761    fn corpus_10_xfa_with_locale_set_packet() {
762        let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><localeSet xmlns="http://www.xfa.org/schema/xfa-locale-set/2.7/"><locale name="en_US" desc="English (United States)"><calendarSymbols name="gregorian"/></locale></localeSet><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="root"><field name="date"/></subform></template></xdp:xdp>"#;
763        let p = parse_xfa_xml(xml);
764        assert!(
765            p.locale_set().is_some(),
766            "localeSet packet should be accessible"
767        );
768        assert!(p.template().is_some());
769        let ls = p.locale_set().unwrap();
770        assert!(ls.contains("en_US"));
771    }
772}