pdf-xfa 1.0.0-beta.7

XFA engine — extraction, layout rendering, font resolution. Experimental and under active development.
Documentation
//! Tier A wave-2 regression: a Tier A static-template XDP with a
//! single `<field presence="invisible">` must flatten as real XFA
//! output (no static-fallback). Drives the bug that wave 1's trace
//! anchor first surfaced.
//!
//! Three tests:
//!
//! 1. `invisible_binding_xdp_flattens_without_static_fallback` —
//!    asserts the fallback tag is NOT in the captured trace and the
//!    pre-existing `bind/invisible_field_binding_ignored` anchor
//!    still fires.
//! 2. `invisible_binding_xdp_layout_produces_one_page` — asserts the
//!    output PDF has the expected single XFA-rendered page (not a
//!    host PDF passthrough).
//! 3. `corrupt_minimal_template_still_takes_fallback` — counterexample:
//!    a tiny corrupt template must still emit
//!    `fallback/static_fallback_taken`. Keeps the wave-1 contract.
//!
//! The fallback global sink is process-wide, so these tests serialize
//! on a file-local mutex (same pattern as M7.2 / wave-1's
//! `static_fallback_trace`).

use std::sync::{Arc, Mutex, OnceLock};

use lopdf::{dictionary, Document, Object, Stream};
use pdf_xfa::flatten_xfa_to_pdf;
use pdf_xfa::layout::trace::{with_global_sink, RecordingSink};

fn global_sink_serializer() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

const XDP_INVISIBLE_BINDING: &str = r#"<?xml version="1.0"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
  <template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
    <subform name="form1" layout="paginate" w="8.5in" h="11in">
      <pageSet><pageArea name="Page1"><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium stock="default" short="612pt" long="792pt"/></pageArea></pageSet>
      <subform name="s1" layout="tb" w="540pt">
        <field name="CustomerName" w="300pt" h="18pt"><ui><textEdit/></ui></field>
        <field name="frmid" presence="invisible" w="100pt" h="10pt"><ui><textEdit/></ui></field>
      </subform>
    </subform>
  </template>
  <xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
    <xfa:data><form1><CustomerName>Alice</CustomerName><frmid>https://s.example/x</frmid></form1></xfa:data>
  </xfa:datasets>
</xdp:xdp>"#;

const XDP_CORRUPT_MINIMAL: &str = 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/"/>
</xdp:xdp>"#;

fn build_pdf(xdp: &str) -> Vec<u8> {
    let mut doc = Document::with_version("1.7");
    let xfa_id = doc.add_object(Object::Stream(Stream::new(
        dictionary! {},
        xdp.as_bytes().to_vec(),
    )));
    let pages_id = doc.new_object_id();
    let content_id = doc.add_object(Object::Stream(Stream::new(
        dictionary! { "Length" => Object::Integer(0_i64) },
        vec![],
    )));
    let page_id = doc.add_object(Object::Dictionary(dictionary! {
        "Type" => Object::Name(b"Page".to_vec()),
        "Parent" => Object::Reference(pages_id),
        "MediaBox" => Object::Array(vec![
            Object::Integer(0), Object::Integer(0),
            Object::Integer(612), Object::Integer(792),
        ]),
        "Contents" => Object::Reference(content_id),
    }));
    doc.objects.insert(
        pages_id,
        Object::Dictionary(dictionary! {
            "Type" => Object::Name(b"Pages".to_vec()),
            "Kids" => Object::Array(vec![Object::Reference(page_id)]),
            "Count" => Object::Integer(1),
        }),
    );
    let acroform_id = doc.add_object(Object::Dictionary(dictionary! {
        "XFA" => Object::Reference(xfa_id),
    }));
    let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
        "Type" => Object::Name(b"Catalog".to_vec()),
        "Pages" => Object::Reference(pages_id),
        "AcroForm" => Object::Reference(acroform_id),
    }));
    doc.trailer.set("Root", Object::Reference(catalog_id));
    let mut out = Vec::new();
    doc.save_to(&mut out).expect("save lopdf");
    out
}

fn captured(pdf: &[u8]) -> (Vec<String>, Option<Vec<u8>>) {
    let _guard = global_sink_serializer()
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let sink: Arc<RecordingSink> = Arc::new(RecordingSink::new());
    let out = with_global_sink(sink.clone() as _, || flatten_xfa_to_pdf(pdf)).ok();
    let tags = sink
        .events()
        .iter()
        .map(|e| format!("{}/{}", e.phase.tag(), e.reason.tag()))
        .collect();
    (tags, out)
}

#[test]
fn invisible_binding_xdp_flattens_without_static_fallback() {
    let pdf = build_pdf(XDP_INVISIBLE_BINDING);
    let (tags, _) = captured(&pdf);
    assert!(
        tags.iter()
            .any(|t| t == "bind/invisible_field_binding_ignored"),
        "pre-existing invisible-binding rule must still fire; got {:?}",
        tags
    );
    assert!(
        !tags.iter().any(|t| t == "fallback/static_fallback_taken"),
        "Tier A static-template XDP must NOT take the static fallback; got {:?}",
        tags
    );
}

#[test]
fn invisible_binding_xdp_layout_produces_one_page() {
    let pdf = build_pdf(XDP_INVISIBLE_BINDING);
    let (_, out) = captured(&pdf);
    let bytes = out.expect("flatten should succeed");
    let doc = Document::load_mem(&bytes).expect("output PDF parses");
    assert_eq!(
        doc.get_pages().len(),
        1,
        "static-template XDP must render to exactly one XFA page"
    );
}

#[test]
fn corrupt_minimal_template_still_takes_fallback() {
    // Counterexample: when the XFA template genuinely has nothing to
    // lay out, the fallback path is still allowed and must still emit
    // the wave-1 trace anchor. The wave-2 fix must not silence this.
    let pdf = build_pdf(XDP_CORRUPT_MINIMAL);
    let (tags, _) = captured(&pdf);
    assert!(
        tags.iter().any(|t| t == "fallback/static_fallback_taken"),
        "corrupt template must still emit fallback/static_fallback_taken; got {:?}",
        tags
    );
}