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/// Scan all PDF stream objects for a datasets packet larger than `min_len`.
172/// Returns the largest found, if any.
173fn scan_for_datasets(pdf: &Pdf, min_len: usize) -> Option<String> {
174    let mut best: Option<String> = None;
175    for obj in pdf.objects() {
176        if let Object::Stream(s) = obj {
177            // Skip image streams — they never contain XFA datasets XML.
178            if is_image_only_stream(&s) {
179                continue;
180            }
181            if let Some(d) = decode_stream(&s) {
182                if d.len() > min_len
183                    && d.contains("<xfa:datasets")
184                    && best.as_ref().is_none_or(|b| d.len() > b.len())
185                {
186                    best = Some(d);
187                }
188            }
189        }
190    }
191    best
192}
193/// extract_xfa_from_bytes.
194pub fn extract_xfa_from_bytes(data: impl Into<pdf_syntax::PdfData>) -> Result<XfaPackets> {
195    let pdf = Pdf::new(data).map_err(|e| XfaError::LoadFailed(format!("{e:?}")))?;
196    extract_xfa(&pdf)
197}
198/// extract_xfa_from_acroform.
199pub fn extract_xfa_from_acroform(pdf: &Pdf) -> Option<XfaPackets> {
200    let xref = pdf.xref();
201    let catalog: Dict<'_> = xref.get(xref.root_id())?;
202    let acroform: Dict<'_> = catalog.get(ACRO_FORM)?;
203    if let Some(stream) = acroform.get::<Stream<'_>>(XFA) {
204        return Some(parse_xfa_xml(&decode_stream(&stream)?));
205    }
206    if let Some(array) = acroform.get::<Array<'_>>(XFA) {
207        return Some(extract_from_array(&array));
208    }
209    None
210}
211
212fn extract_from_array(array: &Array<'_>) -> XfaPackets {
213    let mut packets = XfaPackets::default();
214    let items: Vec<Object<'_>> = array.iter::<Object<'_>>().collect();
215    let mut i = 0;
216    while i + 1 < items.len() {
217        let name = match &items[i] {
218            Object::String(s) => std::string::String::from_utf8_lossy(s.as_bytes()).to_string(),
219            Object::Name(n) => std::string::String::from_utf8_lossy(n.as_ref()).to_string(),
220            _ => {
221                i += 1;
222                continue;
223            }
224        };
225        if let Some(c) = match &items[i + 1] {
226            Object::Stream(s) => decode_stream(s),
227            Object::String(s) => {
228                Some(std::string::String::from_utf8_lossy(s.as_bytes()).to_string())
229            }
230            _ => None,
231        } {
232            packets.packets.push((name, c));
233        }
234        i += 2;
235    }
236    packets
237}
238
239fn scan_for_xfa(pdf: &Pdf) -> Result<XfaPackets> {
240    // Cap the number of streams we decompress to avoid multi-second stalls on
241    // large non-XFA PDFs. XFA XDP streams are typically among the first few
242    // hundred objects. If we haven't found one after 2000 streams, give up.
243    //
244    // PERF (PERF2-02): skip image streams (`/JPXDecode`, `/DCTDecode`,
245    // `/JBIG2Decode`, `/CCITTFaxDecode`) entirely. XFA packets are XML text
246    // wrapped at most by `/FlateDecode`; they never use image codecs. Decoding
247    // a JPEG 2000 image costs hundreds of milliseconds and dominated the
248    // wall-time on `edd_DE44.pdf` (PERF2-01 hotspot report).
249    let mut streams_checked = 0u32;
250    for obj in pdf.objects() {
251        if let Object::Stream(s) = obj {
252            if is_image_only_stream(&s) {
253                continue;
254            }
255            streams_checked += 1;
256            if streams_checked > 2000 {
257                break;
258            }
259            if let Some(d) = decode_stream(&s) {
260                if d.contains("<xdp:xdp") {
261                    return Ok(parse_xfa_xml(&d));
262                }
263            }
264        }
265    }
266    Err(XfaError::PacketNotFound("no XFA content found".to_string()))
267}
268
269fn decode_stream(stream: &Stream<'_>) -> Option<String> {
270    std::string::String::from_utf8(stream.decoded().ok()?).ok()
271}
272
273fn parse_xfa_xml(xml: &str) -> XfaPackets {
274    let mut packets = XfaPackets {
275        full_xml: Some(xml.to_string()),
276        packets: Vec::new(),
277    };
278    let t = xml.trim();
279    let c = t.find("?>").map(|p| &t[p + 2..]).unwrap_or(t).trim();
280    let inner = match c.find('>') {
281        Some(s) => {
282            let rest = &c[s + 1..];
283            rest.rfind("</xdp:xdp>")
284                .map(|e| &rest[..e])
285                .or_else(|| rest.rfind("</xdp>").map(|e| &rest[..e]))
286                .unwrap_or(rest)
287        }
288        None => return packets,
289    };
290    let mut pos = 0;
291    let bytes = inner.as_bytes();
292    while pos < bytes.len() {
293        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
294            pos += 1;
295        }
296        if pos >= bytes.len() {
297            break;
298        }
299        if bytes[pos] != b'<' {
300            pos += 1;
301            continue;
302        }
303        if inner[pos..].starts_with("<!--") {
304            if let Some(e) = inner[pos..].find("-->") {
305                pos += e + 3;
306                continue;
307            }
308        }
309        if inner[pos..].starts_with("<?") {
310            if let Some(e) = inner[pos..].find("?>") {
311                pos += e + 2;
312                continue;
313            }
314        }
315        let ts = pos;
316        pos += 1;
317        let ns = pos;
318        while pos < bytes.len() && bytes[pos] != b'>' && bytes[pos] != b' ' && bytes[pos] != b'/' {
319            pos += 1;
320        }
321        let ft = &inner[ns..pos];
322        let pn = ft.split(':').next_back().unwrap_or(ft);
323        let ct = format!("</{ft}>");
324        let at = format!("</xfa:{pn}>");
325        if let Some(cp) = inner[ts..].find(ct.as_str()) {
326            let ee = ts + cp + ct.len();
327            packets
328                .packets
329                .push((pn.to_string(), inner[ts..ee].to_string()));
330            pos = ee;
331        } else if let Some(cp) = inner[ts..].find(at.as_str()) {
332            let ee = ts + cp + at.len();
333            packets
334                .packets
335                .push((pn.to_string(), inner[ts..ee].to_string()));
336            pos = ee;
337        } else {
338            while pos < bytes.len() && bytes[pos] != b'>' {
339                pos += 1;
340            }
341            pos += 1;
342        }
343    }
344    packets
345}
346
347// ─── Packet validation ───────────────────────────────────────────────────────
348
349/// Summary of what was found (or missing) in a set of [`XfaPackets`].
350///
351/// Returned by [`validate_xfa_packets`].  Intended for diagnostics, logging,
352/// and deciding how to handle unusual or incomplete XFA documents.
353#[derive(Debug, Clone, Default)]
354pub struct PacketValidation {
355    /// `true` when a `template` packet is present.
356    pub has_template: bool,
357    /// `true` when at least one `datasets` packet is present.
358    pub has_datasets: bool,
359    /// `true` when a `config` packet is present.
360    pub has_config: bool,
361    /// Byte length of the template packet (0 if absent).
362    pub template_bytes: usize,
363    /// Byte length of the largest datasets packet (0 if absent).
364    pub datasets_bytes: usize,
365    /// Names of all packets in document order.
366    pub packet_names: Vec<String>,
367    /// Human-readable warnings about missing or suspicious content.
368    pub warnings: Vec<String>,
369}
370
371/// Validate the contents of [`XfaPackets`] and return a [`PacketValidation`].
372///
373/// This function never panics and never fails — it always returns a result,
374/// even for empty or degenerate packet sets.
375pub fn validate_xfa_packets(packets: &XfaPackets) -> PacketValidation {
376    let has_template = packets.template().is_some();
377    let has_datasets = packets.datasets().is_some();
378    let has_config = packets.config().is_some();
379
380    let template_bytes = packets.template().map(|s| s.len()).unwrap_or(0);
381    let datasets_bytes = packets.datasets().map(|s| s.len()).unwrap_or(0);
382    let packet_names = packets.packets.iter().map(|(n, _)| n.clone()).collect();
383
384    let mut warnings = Vec::new();
385
386    if !has_template {
387        warnings.push("No template packet found".to_string());
388    } else if template_bytes < 100 {
389        warnings.push(format!(
390            "Template packet is empty (< 100 bytes) — only {template_bytes} bytes"
391        ));
392    }
393
394    if !has_datasets {
395        warnings.push("No datasets packet".to_string());
396    } else if datasets_bytes < 50 {
397        warnings.push(format!(
398            "Datasets packet is suspiciously small (< 50 bytes) — only {datasets_bytes} bytes"
399        ));
400    }
401
402    PacketValidation {
403        has_template,
404        has_datasets,
405        has_config,
406        template_bytes,
407        datasets_bytes,
408        packet_names,
409        warnings,
410    }
411}
412// ─── Embedded font extraction ────────────────────────────────────────────────
413
414/// Extract embedded font programs from a PDF parsed with pdf-syntax.
415///
416/// Returns a list of `(name, raw_font_bytes)` pairs for every `FontDescriptor`
417/// object that carries a `FontFile`, `FontFile2`, or `FontFile3` stream.
418///
419/// # Relationship to the flatten pipeline
420///
421/// The flattening pipeline (`crate::flatten`) uses a separate lopdf-based
422/// variant (internal, `#[doc(hidden)]`) that additionally captures `/Widths`
423/// arrays and encoding metadata needed for text measurement. That variant
424/// returns [`crate::font_bridge::EmbeddedFontData`] structs and is not part
425/// of the public extraction API.
426///
427/// Use this function when you only need the raw font bytes for inspection,
428/// subsetting, or external embedding outside the flatten pipeline.
429pub fn extract_embedded_fonts(pdf: &Pdf) -> Vec<(String, Vec<u8>)> {
430    use pdf_syntax::object::dict::keys::{FONT_FILE, FONT_FILE2, FONT_FILE3, FONT_NAME, TYPE};
431    use pdf_syntax::object::Name;
432    let mut fonts = Vec::new();
433    for obj in pdf.objects() {
434        let dict = match &obj {
435            Object::Dict(d) => d.clone(),
436            Object::Stream(s) => s.dict().clone(),
437            _ => continue,
438        };
439        if dict
440            .get::<Name>(TYPE)
441            .is_none_or(|n| n.as_ref() != b"FontDescriptor")
442        {
443            continue;
444        }
445        let name = dict
446            .get::<Name>(FONT_NAME)
447            .map(|n| std::string::String::from_utf8_lossy(n.as_ref()).to_string())
448            .unwrap_or_default();
449        for key in [FONT_FILE2, FONT_FILE, FONT_FILE3] {
450            if let Some(s) = dict.get::<Stream<'_>>(key) {
451                if let Ok(d) = s.decoded() {
452                    if !d.is_empty() {
453                        fonts.push((name.clone(), d));
454                        break;
455                    }
456                }
457            }
458        }
459    }
460    fonts
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    #[test]
467    fn parse_xfa_packets() {
468        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>"#;
469        let p = parse_xfa_xml(xml);
470        assert_eq!(p.packets.len(), 2);
471        assert!(p.template().is_some());
472        assert!(p.datasets().is_some());
473    }
474    #[test]
475    fn empty_xfa() {
476        let p = parse_xfa_xml(r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"></xdp:xdp>"#);
477        assert_eq!(p.packets.len(), 0);
478    }
479
480    #[test]
481    fn get_packet_missing_returns_none() {
482        let p = parse_xfa_xml(r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"></xdp:xdp>"#);
483        assert!(p.get_packet("template").is_none());
484        assert!(p.get_packet("nonexistent").is_none());
485        assert!(p.config().is_none());
486        assert!(p.locale_set().is_none());
487    }
488
489    #[test]
490    fn full_xml_preserved() {
491        // full_xml should always capture the entire input string.
492        let xml =
493            r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"></xdp:xdp>"#;
494        let p = parse_xfa_xml(xml);
495        let stored = p.full_xml.as_deref().unwrap_or("");
496        assert!(stored.contains("xdp:xdp"));
497    }
498
499    #[test]
500    fn config_packet_parsed() {
501        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>"#;
502        let p = parse_xfa_xml(xml);
503        assert_eq!(p.packets.len(), 1);
504        assert!(p.config().is_some());
505        assert!(p.template().is_none());
506    }
507
508    #[test]
509    fn multiple_packets_order_preserved() {
510        // template must come before datasets — order matches the XDP source order.
511        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>"#;
512        let p = parse_xfa_xml(xml);
513        assert_eq!(p.packets.len(), 2);
514        assert_eq!(p.packets[0].0, "template");
515        assert_eq!(p.packets[1].0, "datasets");
516        assert!(p.template().is_some());
517        assert!(p.datasets().is_some());
518    }
519
520    // ── PacketValidation tests (issue #1085) ──────────────────────────────
521
522    #[test]
523    fn validate_complete_packets_no_warnings() {
524        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>"#;
525        let p = parse_xfa_xml(xml);
526        let v = validate_xfa_packets(&p);
527        assert!(v.has_template);
528        assert!(v.has_datasets);
529        assert!(v.template_bytes > 0);
530        assert!(v.datasets_bytes > 0);
531        assert!(
532            v.warnings.is_empty(),
533            "expected no warnings, got: {:?}",
534            v.warnings
535        );
536    }
537
538    #[test]
539    fn validate_missing_template_produces_warning() {
540        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>"#;
541        let p = parse_xfa_xml(xml);
542        let v = validate_xfa_packets(&p);
543        assert!(!v.has_template);
544        assert!(v
545            .warnings
546            .iter()
547            .any(|w| w.contains("No template packet found")));
548    }
549
550    #[test]
551    fn validate_missing_datasets_produces_warning() {
552        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>"#;
553        let p = parse_xfa_xml(xml);
554        let v = validate_xfa_packets(&p);
555        assert!(!v.has_datasets);
556        assert!(v.warnings.iter().any(|w| w.contains("No datasets packet")));
557    }
558
559    #[test]
560    fn validate_tiny_template_produces_warning() {
561        let mut p = XfaPackets::default();
562        p.packets.push(("template".to_string(), "<t/>".to_string()));
563        p.packets.push((
564            "datasets".to_string(),
565            "<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data/></xfa:datasets>".to_string(),
566        ));
567        let v = validate_xfa_packets(&p);
568        assert!(v.warnings.iter().any(|w| w.contains("< 100 bytes")));
569    }
570
571    #[test]
572    fn validate_tiny_datasets_produces_warning() {
573        let mut p = XfaPackets::default();
574        // Give a substantial template so that warning comes from datasets only.
575        p.packets.push((
576            "template".to_string(),
577            "<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(),
578        ));
579        p.packets
580            .push(("datasets".to_string(), "<ds/>".to_string()));
581        let v = validate_xfa_packets(&p);
582        assert!(v.warnings.iter().any(|w| w.contains("< 50 bytes")));
583    }
584
585    #[test]
586    fn validate_packet_names_list() {
587        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>"#;
588        let p = parse_xfa_xml(xml);
589        let v = validate_xfa_packets(&p);
590        assert!(v.packet_names.contains(&"config".to_string()));
591        assert!(v.packet_names.contains(&"template".to_string()));
592        assert!(v.has_config);
593    }
594
595    // ── XFA corpus tests (issue #1086) ────────────────────────────────────
596    // Ten synthetic tests covering representative XFA document patterns.
597    // Each test uses small in-memory XML strings — no real PDFs required.
598
599    /// 1. Static XFA form detection via baseProfile.
600    #[test]
601    fn corpus_01_static_xfa_form_detection() {
602        use crate::classify::{detect_xfa_type_from_packets, XfaType};
603        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>"#;
604        let p = parse_xfa_xml(xml);
605        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Static);
606    }
607
608    /// 2. Dynamic XFA form detection (no baseProfile constraint).
609    #[test]
610    fn corpus_02_dynamic_xfa_form_detection() {
611        use crate::classify::{detect_xfa_type_from_packets, XfaType};
612        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>"#;
613        let p = parse_xfa_xml(xml);
614        assert_eq!(detect_xfa_type_from_packets(&p), XfaType::Dynamic);
615    }
616
617    /// 3. XFA with multiple packets (template + datasets + config).
618    #[test]
619    fn corpus_03_multiple_packets() {
620        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>"#;
621        let p = parse_xfa_xml(xml);
622        assert_eq!(p.packets.len(), 3, "should have config, template, datasets");
623        assert!(p.config().is_some());
624        assert!(p.template().is_some());
625        assert!(p.datasets().is_some());
626    }
627
628    /// 4. XFA with no datasets (template-only — blank form, no data bound).
629    #[test]
630    fn corpus_04_template_only_no_datasets() {
631        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>"#;
632        let p = parse_xfa_xml(xml);
633        assert!(p.template().is_some());
634        assert!(p.datasets().is_none());
635        let v = validate_xfa_packets(&p);
636        assert!(!v.has_datasets);
637        assert!(v.warnings.iter().any(|w| w.contains("No datasets")));
638    }
639
640    /// 5. XFA with binary-like image data embedded in datasets (base64 blob).
641    #[test]
642    fn corpus_05_xfa_with_image_data_in_datasets() {
643        // Simulate datasets containing a base64-encoded image field.
644        let b64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
645        let xml = format!(
646            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>"#
647        );
648        let p = parse_xfa_xml(&xml);
649        assert!(p.template().is_some());
650        assert!(p.datasets().is_some());
651        let ds = p.datasets().unwrap();
652        assert!(ds.contains(b64_image), "datasets should contain image data");
653    }
654
655    /// 6. Non-XFA PDF (empty bytes) returns XfaType::None.
656    #[test]
657    fn corpus_06_non_xfa_pdf_returns_none() {
658        use crate::classify::{detect_xfa_type, XfaType};
659        // A plain PDF header with no AcroForm/XFA.
660        let not_xfa: &[u8] = b"%PDF-1.4\n%%EOF";
661        assert_eq!(detect_xfa_type(not_xfa), XfaType::None);
662    }
663
664    /// 7. XFA with config packet — config is correctly parsed and accessible.
665    #[test]
666    fn corpus_07_xfa_with_config_packet() {
667        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>"#;
668        let p = parse_xfa_xml(xml);
669        assert!(p.config().is_some());
670        let cfg = p.config().unwrap();
671        assert!(cfg.contains("packets"));
672        let v = validate_xfa_packets(&p);
673        assert!(v.has_config);
674    }
675
676    /// 8. Empty datasets packet (incremental save pattern — original blank form).
677    #[test]
678    fn corpus_08_empty_datasets_incremental_save_pattern() {
679        // Two datasets entries: original blank (small) and filled (larger).
680        let mut p = XfaPackets::default();
681        p.packets.push((
682            "template".to_string(),
683            "<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(),
684        ));
685        // Blank (incremental save artefact — very small):
686        p.packets.push((
687            "datasets".to_string(),
688            "<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"/>".to_string(),
689        ));
690        // Filled (the real data):
691        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()));
692        // datasets() must return the LARGEST entry.
693        let ds = p.datasets().expect("datasets should exist");
694        assert!(
695            ds.contains("29.97"),
696            "should return the larger/filled datasets"
697        );
698    }
699
700    /// 9. Large template with many fields — validation should have no warnings.
701    #[test]
702    fn corpus_09_large_template_many_fields() {
703        // Build a template with 20 fields to ensure validation handles size correctly.
704        let fields: String = (1..=20)
705            .map(|i| format!("<field name=\"field{i}\"><ui><textEdit/></ui></field>"))
706            .collect();
707        let xml = format!(
708            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>"#,
709            (1..=20)
710                .map(|i| format!("<field{i}>val{i}</field{i}>"))
711                .collect::<String>()
712        );
713        let p = parse_xfa_xml(&xml);
714        let v = validate_xfa_packets(&p);
715        assert!(v.has_template);
716        assert!(v.has_datasets);
717        assert!(
718            v.template_bytes >= 100,
719            "large template should exceed 100 bytes"
720        );
721        assert!(
722            v.warnings.is_empty(),
723            "no warnings expected: {:?}",
724            v.warnings
725        );
726    }
727
728    /// 10. XFA with localeSet packet — localeSet is correctly accessible.
729    #[test]
730    fn corpus_10_xfa_with_locale_set_packet() {
731        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>"#;
732        let p = parse_xfa_xml(xml);
733        assert!(
734            p.locale_set().is_some(),
735            "localeSet packet should be accessible"
736        );
737        assert!(p.template().is_some());
738        let ls = p.locale_set().unwrap();
739        assert!(ls.contains("en_US"));
740    }
741}