Some checks are pending
Security Scan / security-scan (push) Waiting to run
Dynamic XFA forms — real-estate forms, mortgage forms, government forms —
have been a silent failure mode for every tool in this server: the layout
and fields live in an XFA program that only Adobe's runtime executes, so
PyMuPDF/pdfium/MuPDF only see the "Open in Adobe Reader" placeholder page.
extract_form_data returned "document closed", convert_to_images returned
the placeholder, analyze_pdf_health reported total_pages=1 — all
correctly per the visible PDF, but all misleading about what the form
actually contains.
New capabilities:
- src/mcp_pdf/xfa.py — XFA detection, packet extraction, field parsing,
classification. No new deps (pypdf + stdlib ElementTree). Lifted from
a working prototype with attribution preserved; parameterized for
producer profiles.
- is_xfa_pdf — MCP tool to detect XFA presence + classify dynamic vs
static. Use for branching BEFORE extract_form_data or convert_to_images.
- extract_xfa_fields — MCP tool that parses the XFA template for field
names, captions, UI types. Splits fields into shared (cross-form
canonical Global_Info-* vocabulary), positional (opaque codes like
p01tf022), and plumbing (producer internals, dropped). Defaults to
the zipForm producer profile; pass profile="generic" + custom regex
patterns for other producers. canonical_separator selects _ / . / -.
UX fixes for existing tools (no more cryptic failures on dynamic XFA):
- extract_form_data — diagnoses dynamic XFA and returns
{error, hint: "extract_xfa_fields"} instead of "document closed"
- convert_to_images — still produces the rendered image (caller may
want the placeholder), but now warns that it's not the real form
- analyze_pdf_health — surfaces is_xfa + xfa_type in document_stats,
adds a warning for dynamic XFA
Cross-tool alignment:
- extract_form_data field-type strings aligned to the same six-term
vocabulary as XFA (text/checkbox/radio/dropdown/date/signature +
button/unknown edge categories). listbox + combobox both collapse
to "dropdown" — the widget-hover distinction wasn't semantic.
Tests: 31/31 passing against the synthetic XFA fixture
(tests/fixtures/xfa/synthetic_dynamic_xfa.pdf). The fixture is
hand-built, license-clean, ~2 KB, and exercises all three classification
categories including the "denylist beats shared-prefix" invariant
(Global_Info-Invisibind-Test drops despite the shared prefix).
Build hygiene:
- .gitignore exception !tests/fixtures/**/*.pdf so fixtures survive
the global *.pdf rule
- pyproject.toml force-include for the fixture (uses hatchling's
force-include not include — the latter is restrictive, not additive)
Coordinated via the agent-thread protocol; full design history is
in docs/agent-threads/xfa-form-support/ (excluded from the sdist).
313 lines
13 KiB
Python
313 lines
13 KiB
Python
"""Tests for XFA (dynamic Adobe LiveCycle form) support.
|
|
|
|
Pins behavior against the synthetic XFA fixture contributed via the
|
|
agent-thread at ``docs/agent-threads/xfa-form-support/`` (specifically the
|
|
expected-output table in ``005-iar-attaches-fixture.md``). Each assertion
|
|
here corresponds to a row in that table.
|
|
|
|
The fixture is hand-built, license-clean, and small (~2 KB). Its README at
|
|
``tests/fixtures/xfa/README.md`` documents what each field exercises.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from mcp_pdf.xfa import (
|
|
canonicalize,
|
|
classify_fields,
|
|
extract_xfa_parts,
|
|
extract_xfa_schema,
|
|
fields_from_pdf,
|
|
is_xfa_pdf,
|
|
parse_fields,
|
|
_ZIPFORM_PROFILE,
|
|
_GENERIC_PROFILE,
|
|
)
|
|
|
|
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "xfa"
|
|
SYNTHETIC_FIXTURE = FIXTURE_DIR / "synthetic_dynamic_xfa.pdf"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestXfaDetection:
|
|
"""is_xfa_pdf and the catalog /NeedsRendering flag."""
|
|
|
|
def test_synthetic_fixture_detected_as_dynamic_xfa(self):
|
|
result = is_xfa_pdf(str(SYNTHETIC_FIXTURE))
|
|
assert result["is_xfa"] is True
|
|
assert result["xfa_type"] == "dynamic"
|
|
assert result["has_acroform"] is True
|
|
|
|
def test_nonexistent_pdf_returns_not_xfa(self):
|
|
result = is_xfa_pdf("/nonexistent/path.pdf")
|
|
assert result["is_xfa"] is False
|
|
assert result["xfa_type"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# XFA packet extraction (array-form parsing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestXfaPartsExtraction:
|
|
"""The fixture uses the array form [template, datasets] — exercises the
|
|
name/stream-pair parsing path, not the single-stream path."""
|
|
|
|
def test_extracts_template_and_datasets(self):
|
|
parts = extract_xfa_parts(str(SYNTHETIC_FIXTURE))
|
|
assert "template" in parts
|
|
assert "datasets" in parts
|
|
assert isinstance(parts["template"], bytes)
|
|
assert isinstance(parts["datasets"], bytes)
|
|
|
|
def test_template_is_parseable_xml(self):
|
|
parts = extract_xfa_parts(str(SYNTHETIC_FIXTURE))
|
|
# If <template> isn't well-formed XML, parse_fields silently returns []
|
|
# — which would also pass if the template were empty. So assert content
|
|
# AND that parse_fields actually finds fields.
|
|
assert len(parts["template"]) > 0
|
|
fields = parse_fields(parts["template"])
|
|
assert len(fields) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Field parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFieldParsing:
|
|
"""parse_fields against the fixture's 8-field template."""
|
|
|
|
def test_finds_eight_raw_fields(self):
|
|
"""All 8 fields from fixture/README.md table, before classification."""
|
|
fields = fields_from_pdf(str(SYNTHETIC_FIXTURE))
|
|
assert len(fields) == 8
|
|
|
|
def test_caption_harvested_from_field_with_caption(self):
|
|
"""The fixture has one in-field <caption> on Test-Property-Address."""
|
|
fields = fields_from_pdf(str(SYNTHETIC_FIXTURE))
|
|
property_address = next(
|
|
f for f in fields if f.name == "Global_Info-Test-Property-Address"
|
|
)
|
|
assert property_address.caption == "Property Address"
|
|
|
|
def test_ui_types_extracted(self):
|
|
fields = fields_from_pdf(str(SYNTHETIC_FIXTURE))
|
|
by_name = {f.name: f for f in fields}
|
|
# textEdit -> text in vocabulary; checkButton -> checkbox
|
|
assert by_name["Global_Info-Test-Property-Address"].ui_type == "textEdit"
|
|
assert by_name["Global_Info-Test-Buyer-Name"].ui_type == "checkButton"
|
|
assert by_name["p01cb002"].ui_type == "checkButton"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Classification (the critical behaviors)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestClassificationBehavior:
|
|
"""All the rows from the fixture README's expected-output table."""
|
|
|
|
def test_classification_counts_match_fixture_spec(self):
|
|
"""README: 2 shared, 3 positional, 3 plumbing dropped."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
shared = [f for f in result["fields"] if f["category"] == "shared"]
|
|
positional = [f for f in result["fields"] if f["category"] == "positional"]
|
|
assert len(shared) == 2
|
|
assert len(positional) == 3
|
|
assert len(result["plumbing_fields_dropped"]) == 3
|
|
|
|
def test_shared_canonical_names(self):
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
canonical = set(result["shared_fields"].values())
|
|
assert canonical == {"test_property_address", "test_buyer_name"}
|
|
|
|
def test_positional_codes_preserved_verbatim(self):
|
|
"""Opaque codes have no canonical_name; original is preserved as-is."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
positional = sorted(
|
|
f["original"] for f in result["fields"] if f["category"] == "positional"
|
|
)
|
|
assert positional == ["p01cb002", "p01tf001", "p02te001"]
|
|
# Also: no canonical_name on positional fields (response shape contract)
|
|
for f in result["fields"]:
|
|
if f["category"] == "positional":
|
|
assert "canonical_name" not in f
|
|
|
|
def test_plumbing_drops_match_fixture_spec(self):
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
assert set(result["plumbing_fields_dropped"]) == {
|
|
"tbd1", "folder_id", "Global_Info-Invisibind-Test",
|
|
}
|
|
|
|
def test_denylist_beats_shared_prefix(self):
|
|
"""The most important invariant: Global_Info-Invisibind-Test has the
|
|
shared prefix AND matches the invisibind denylist regex — plumbing
|
|
MUST win. If this regresses, value-collection pipelines would silently
|
|
try to fill a producer-internal field as a user-facing form field."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
# It must be in plumbing_dropped, NOT in fields, NOT in shared_fields.
|
|
assert "Global_Info-Invisibind-Test" in result["plumbing_fields_dropped"]
|
|
assert not any(
|
|
f["original"] == "Global_Info-Invisibind-Test"
|
|
for f in result["fields"]
|
|
)
|
|
assert "Global_Info-Invisibind-Test" not in result["shared_fields"]
|
|
|
|
def test_positional_regex_does_not_overmatch_different_pages(self):
|
|
"""The fixture deliberately includes p01tf001, p01cb002, AND p02te001 —
|
|
the regex must accept all three different page prefixes (01 vs 02) and
|
|
type codes (tf, cb, te). If the regex anchored to a specific page or
|
|
type, this would silently miss fields."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
positional = {
|
|
f["original"] for f in result["fields"] if f["category"] == "positional"
|
|
}
|
|
assert positional == {"p01tf001", "p01cb002", "p02te001"}
|
|
|
|
def test_field_count_is_raw_count(self):
|
|
"""field_count reports the raw field count BEFORE plumbing drops,
|
|
for auditability — separate from field_count_after_classification."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
assert result["field_count"] == 8
|
|
assert result["field_count_after_classification"] == 5
|
|
|
|
def test_original_present_on_every_field(self):
|
|
"""Round-trip key contract from 004: every field carries 'original'."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
for f in result["fields"]:
|
|
assert "original" in f
|
|
assert f["original"]
|
|
|
|
def test_caption_used_as_label_for_shared(self):
|
|
"""When a shared field has an in-field <caption>, use it as label
|
|
instead of deriving from the canonical name."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE))
|
|
property_address = next(
|
|
f for f in result["fields"]
|
|
if f["original"] == "Global_Info-Test-Property-Address"
|
|
)
|
|
assert property_address["label"] == "Property Address"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Canonicalization (pure function)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCanonicalization:
|
|
"""The mechanical name transform — separate from classification."""
|
|
|
|
@pytest.mark.parametrize("original, expected", [
|
|
("Global_Info-Test-Property-Address", "test_property_address"),
|
|
("Global_Info-Seller-Broker-Entity-Name", "seller_broker_entity_name"),
|
|
("Global_Info-Buyer-Name", "buyer_name"),
|
|
# camelCase boundaries get an underscore
|
|
("Global_Info-BuyerName", "buyer_name"),
|
|
# Special chars collapse to single underscores
|
|
("Global_Info--Field--Name", "field_name"),
|
|
# Without the prefix, just normalizes
|
|
("Some-Other-Field", "some_other_field"),
|
|
])
|
|
def test_snake_case(self, original, expected):
|
|
assert canonicalize(original, _ZIPFORM_PROFILE, separator="_") == expected
|
|
|
|
def test_dotted_separator(self):
|
|
result = canonicalize(
|
|
"Global_Info-Seller-Broker-Name", _ZIPFORM_PROFILE, separator=".",
|
|
)
|
|
assert result == "seller.broker.name"
|
|
|
|
def test_kebab_separator(self):
|
|
result = canonicalize(
|
|
"Global_Info-Property-Address", _ZIPFORM_PROFILE, separator="-",
|
|
)
|
|
assert result == "property-address"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Profiles (zipform vs generic + caller extensions)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestProfiles:
|
|
def test_generic_profile_keeps_positional_as_other(self):
|
|
"""The generic profile has NO positional patterns, so p01tf001 etc.
|
|
get classified as 'other' (not 'positional') without caller extension."""
|
|
result = extract_xfa_schema(str(SYNTHETIC_FIXTURE), profile="generic")
|
|
positional_count = sum(
|
|
1 for f in result["fields"] if f["category"] == "positional"
|
|
)
|
|
other_count = sum(1 for f in result["fields"] if f["category"] == "other")
|
|
assert positional_count == 0
|
|
# generic profile still drops nothing as plumbing (no patterns), so
|
|
# Invisibind/tbd1/folder_id all show up as 'other' too
|
|
assert other_count > 0
|
|
|
|
def test_caller_can_add_plumbing_pattern(self):
|
|
"""Custom plumbing patterns get appended to the profile's denylist."""
|
|
result = extract_xfa_schema(
|
|
str(SYNTHETIC_FIXTURE),
|
|
profile="zipform",
|
|
extra_plumbing_patterns=[r"^p01tf"],
|
|
)
|
|
# p01tf001 should now be dropped as plumbing
|
|
assert "p01tf001" in result["plumbing_fields_dropped"]
|
|
assert result["profile_used"].endswith("+custom")
|
|
|
|
def test_caller_can_add_positional_pattern(self):
|
|
"""Custom positional patterns identify additional opaque codes."""
|
|
# Generic profile + custom positional regex should categorize p01tf001
|
|
result = extract_xfa_schema(
|
|
str(SYNTHETIC_FIXTURE),
|
|
profile="generic",
|
|
extra_positional_patterns=[r"^p\d+[a-z]+\d+$"],
|
|
)
|
|
positional = [f for f in result["fields"] if f["category"] == "positional"]
|
|
assert len(positional) == 3
|
|
|
|
def test_caller_can_add_exact_plumbing_names(self):
|
|
"""Custom exact plumbing names extend the denylist."""
|
|
result = extract_xfa_schema(
|
|
str(SYNTHETIC_FIXTURE),
|
|
extra_plumbing_exact=["p01tf001"],
|
|
)
|
|
assert "p01tf001" in result["plumbing_fields_dropped"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MCP tool integration (mixin-level, async)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestXfaMcpTools:
|
|
"""Verify the MCP tool entry points wire through to xfa.py correctly."""
|
|
|
|
def test_is_xfa_pdf_tool_detects_dynamic(self):
|
|
from mcp_pdf.mixins_official.form_management import FormManagementMixin
|
|
mixin = FormManagementMixin()
|
|
result = asyncio.run(mixin.is_xfa_pdf(str(SYNTHETIC_FIXTURE)))
|
|
assert result["is_xfa"] is True
|
|
assert result["xfa_type"] == "dynamic"
|
|
|
|
def test_extract_xfa_fields_tool_returns_expected_shape(self):
|
|
from mcp_pdf.mixins_official.form_management import FormManagementMixin
|
|
mixin = FormManagementMixin()
|
|
result = asyncio.run(mixin.extract_xfa_fields(str(SYNTHETIC_FIXTURE)))
|
|
assert result["is_xfa"] is True
|
|
assert result["field_count"] == 8
|
|
assert result["field_count_after_classification"] == 5
|
|
assert result["profile_used"] == "zipform"
|
|
|
|
def test_extract_form_data_diagnoses_dynamic_xfa(self):
|
|
"""The big UX win from 003+004: instead of cryptic 'document closed',
|
|
callers get a clear error + hint pointing to extract_xfa_fields."""
|
|
from mcp_pdf.mixins_official.form_management import FormManagementMixin
|
|
mixin = FormManagementMixin()
|
|
result = asyncio.run(mixin.extract_form_data(str(SYNTHETIC_FIXTURE)))
|
|
assert result["success"] is False
|
|
assert result.get("is_xfa") is True
|
|
assert result.get("xfa_type") == "dynamic"
|
|
assert "extract_xfa_fields" in result.get("hint", "")
|