Forms: say so when a document is an XFA form (#60)

XFA forms aren't read yet, and opening one gave no hint: a dynamic XFA form
showed only its "Please wait…" placeholder page, and a static one looked like
an ordinary form though Acrobat shows its XFA data in preference to the
fields that get filled.

- render: DocInfo.xfa (Static / Dynamic) from /AcroForm /XFA; dynamic when
  /NeedsRendering is true or there are no fields. Exported for doc_info
  (automation and CLI) as "xfa".
- ui: the notice bar explains each case (above the form-fields notice); a
  static form keeps its Highlight fields button.
- Tests: tests/xfa.rs (dynamic with and without /NeedsRendering, static,
  ordinary forms, a malformed /XFA). Parity notes for form.xfa-static and
  form.xfa-dynamic (still planned: this explains XFA, it doesn't support it).

Reported by @Sudachi-dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 07:08:11 -04:00
parent 93221c6886
commit ea2ed3d911
7 changed files with 117 additions and 4 deletions

View File

@ -112,6 +112,7 @@ fn info(args: &[String]) -> Result<(), String> {
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
"xfa": info.xfa.map(|x| match x { printcraft_render::Xfa::Static => "static", printcraft_render::Xfa::Dynamic => "dynamic" }),
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),

View File

@ -1482,6 +1482,8 @@ fn info(d: &Document) -> Value {
"creator": i.creator, "producer": i.producer,
"tagged": i.tagged,
"has_javascript": i.has_javascript,
// XFA forms aren't read yet: "static" (fields work, XFA data ignored) or "dynamic" (placeholder pages).
"xfa": i.xfa.map(|x| match x { printcraft_render::Xfa::Static => "static", printcraft_render::Xfa::Dynamic => "dynamic" }),
"security": security,
"pages": i.pages.iter().enumerate().map(|(n, p)| json!({
"page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation,

View File

@ -38,6 +38,8 @@ pub struct DocInfo {
pub encrypted: bool,
pub tagged: bool,
pub has_javascript: bool,
/// The form is (also) an XFA form, which isn't read yet (`form.xfa-*` in the parity list).
pub xfa: Option<Xfa>,
pub pages: Vec<PageInfo>,
pub outline: Vec<OutlineItem>,
pub annotations: Vec<Annotation>,
@ -49,6 +51,17 @@ pub struct DocInfo {
pub warnings: Vec<String>,
}
/// What kind of XFA form a document has (`/AcroForm /XFA`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Xfa {
/// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold
/// its data, which Acrobat shows in preference to the fields).
Static,
/// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields): the
/// PDF pages are only a placeholder.
Dynamic,
}
#[derive(Clone, Debug)]
pub struct PageInfo {
/// Displayed size in points, after `/Rotate` and `/UserUnit`.
@ -341,6 +354,10 @@ impl<'a> Inspector<'a> {
}
}
info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false);
if catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).is_some_and(|form| form.get(b"XFA").is_ok()) {
info.xfa = Some(if needs_rendering || info.fields.is_empty() { Xfa::Dynamic } else { Xfa::Static });
}
self.layers(catalog, &mut info.layers);
self.fonts(&mut info.fonts);
if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) {

View File

@ -13,8 +13,8 @@ mod raster;
pub mod text;
pub use inspect::{
Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, attachment_data,
inspect, pretty_date,
Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, Xfa,
attachment_data, inspect, pretty_date,
};
pub use raster::{MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, effective_scale};
pub use text::{PageText, TextGlyph};

View File

@ -1842,6 +1842,18 @@ fn notices(
let mut open_repairs = false;
let msg = if secured {
Some(("lock", "This document is secured. Some changes are restricted by its security settings.".to_string(), false))
} else if info.xfa == Some(printcraft_render::Xfa::Dynamic) {
Some((
"triangle-alert",
"This is a dynamic XFA form, which PrintCraft can't display yet. What you see is the file's placeholder page.".to_string(),
false,
))
} else if info.xfa == Some(printcraft_render::Xfa::Static) {
Some((
"triangle-alert",
"This form also contains XFA data, which PrintCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead.".to_string(),
true,
))
} else if !info.fields.is_empty() {
Some(("text-cursor-input", format!("This document contains {} interactive form fields.", info.fields.len()), true))
} else if repaired {

View File

@ -0,0 +1,81 @@
//! XFA forms (#60): not read yet, so the document says so instead of silently showing a
//! placeholder page or fields whose values Acrobat would override.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use printcraft_ui_egui::PrintCraftApp;
/// A one-page PDF with a proper xref; `acroform` is the catalog's /AcroForm (or empty), `extra`
/// more catalog entries, `objects` extra objects numbered from 5.
fn pdf(acroform: &str, extra: &str, objects: &[&str]) -> Vec<u8> {
let mut objs: Vec<String> = vec![
format!("<< /Type /Catalog /Pages 2 0 R {acroform} {extra} >>"),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
];
objs.extend(objects.iter().map(|o| o.to_string()));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
const XFA_PACKET: &str = "<< /Length 52 >>\nstream\n<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"></xdp:xdp>\nendstream";
fn open(bytes: Vec<u8>) -> Harness<'static, PrintCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 800.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("form.pdf", None, bytes).expect("opens");
app
});
h.run_steps(4);
h
}
fn xfa(h: &Harness<'static, PrintCraftApp>) -> Option<printcraft_render::Xfa> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.xfa
}
#[test]
fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() {
// No fields, the form lives in the XFA packets (an array of name/stream pairs here).
let h = open(pdf("/AcroForm << /Fields [] /XFA [(template) 5 0 R] >>", "/NeedsRendering true", &[XFA_PACKET]));
assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Dynamic));
h.get_by_label_contains("dynamic XFA form");
// Without /NeedsRendering, no fields still means nothing to fill but the placeholder.
let h = open(pdf("/AcroForm << /Fields [] /XFA 5 0 R >>", "", &[XFA_PACKET]));
assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Dynamic));
}
#[test]
fn a_static_xfa_form_can_be_filled_but_warns_about_its_xfa_data() {
let field = "<< /FT /Tx /T (name) /Rect [20 20 200 40] /Type /Annot /Subtype /Widget /P 3 0 R >>";
let h = open(pdf("/AcroForm << /Fields [6 0 R] /XFA 5 0 R >>", "", &[XFA_PACKET, field]));
assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Static));
h.get_by_label_contains("also contains XFA data");
// The fields can still be highlighted from the notice.
h.get_by_label("Highlight fields");
}
#[test]
fn ordinary_forms_and_documents_have_no_xfa_notice() {
let field = "<< /FT /Tx /T (name) /Rect [20 20 200 40] /Type /Annot /Subtype /Widget /P 3 0 R >>";
let h = open(pdf("/AcroForm << /Fields [5 0 R] >>", "", &[field]));
assert_eq!(xfa(&h), None);
assert!(h.query_by_label_contains("XFA").is_none());
h.get_by_label_contains("interactive form fields");
// A malformed /XFA (a number) still counts as XFA; it never crashes the open.
let h = open(pdf("/AcroForm << /Fields [] /XFA 42 >>", "", &[]));
assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Dynamic));
}

View File

@ -5107,7 +5107,7 @@ status = "planned"
commands = []
tools = []
evidence = []
notes = ""
notes = "not read yet; detected on open (DocInfo.xfa, doc_info \"xfa\") and a notice says the fields can be filled but Acrobat may show the XFA values instead (#60)"
[[feature]]
id = "form.xfa-dynamic"
@ -5119,7 +5119,7 @@ status = "planned"
commands = []
tools = []
evidence = []
notes = ""
notes = "not rendered yet; detected on open and a notice says the page shown is the file's placeholder (#60)"
[[feature]]
id = "form.xfa-formcalc"