diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ec305b1ca..d7f521106 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -3935,6 +3935,40 @@ fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() {
assert!(!doc.dirty);
}
+/// #3180: an XFA form with no fields whose configuration says `dynamicRender` is `forbidden` is a
+/// static form: its pages are the form. Opening it used to lay the template out into new pages
+/// and write them as a revision, so even a save with no change replaced the pages.
+#[test]
+fn an_xfa_form_without_fields_that_forbids_dynamic_rendering_keeps_its_pages() {
+ let config = r#"forbidden"#;
+ let template = pdfcraft_xfa::fixtures::template(1);
+ let with_config = template.replacen(" plain.len() && doc.bytes.starts_with(&plain[..]), "the layout is a revision");
+}
+
#[test]
fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1"));
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 3c3988b7b..5893d38ee 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -30,6 +30,10 @@ const LOAD_STREAM_LIMIT: usize = 256 << 20;
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
+/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to
+/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template.
+const MAX_XFA_CONFIG_BYTES: usize = 16 << 20;
+
/// File specifications retained from a remote-Go-To action are deliberately small.
const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096;
const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
@@ -87,8 +91,8 @@ 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.
+ /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a
+ /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder.
Dynamic,
}
@@ -663,8 +667,12 @@ 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 });
+ if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) {
+ // A form with no fields is taken to be laid out from its template, unless its
+ // configuration says it is a static form (#3180): Designer writes
+ // `forbidden` for one, whose pages are the form.
+ let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden");
+ info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static });
}
self.layers(catalog, &mut info.layers);
info.layer_groups = self.layer_groups(catalog);
@@ -729,6 +737,28 @@ impl<'a> Inspector<'a> {
}
}
+ /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under
+ /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is
+ /// either one stream holding the whole XDP or an array of packet names and streams (ISO
+ /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over
+ /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element.
+ fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option {
+ let stream = match self.resolve(xfa) {
+ Object::Array(items) => {
+ let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?;
+ self.resolve(items.get(at.checked_add(1)?)?)
+ }
+ whole => whole,
+ };
+ let stream = stream.as_stream().ok()?;
+ let bytes = if stream.dict.get(b"Filter").is_ok() {
+ stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()?
+ } else {
+ stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec()
+ };
+ xml_element_text(&bytes, b"dynamicRender")
+ }
+
fn resolve(&self, o: &'a Object) -> &'a Object {
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
}
@@ -1614,6 +1644,33 @@ pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -
catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p))))
}
+/// The trimmed text of the first `` element in `xml` (any namespace prefix), if it is plain
+/// text. A byte scan, not an XML parser: enough for one known element in an XFA packet, whose
+/// tags Designer writes with a line break before each `>`.
+fn xml_element_text(xml: &[u8], name: &[u8]) -> Option {
+ let mut from = 0;
+ while let Some(lt) = xml.get(from..)?.iter().position(|&b| b == b'<').map(|i| i + from) {
+ from = lt + 1;
+ let tag = xml.get(from..)?;
+ // Skip a namespace prefix (`xfa:name`), then require the name followed by `>` or a space
+ // (a self-closing `` holds no text).
+ let local = tag
+ .iter()
+ .position(|&b| b == b':' || b == b'>' || b == b'/' || b.is_ascii_whitespace())
+ .map_or(tag, |i| if tag.get(i) == Some(&b':') { tag.get(i + 1..).unwrap_or_default() } else { tag });
+ let Some(after) = local.strip_prefix(name) else { continue };
+ if !matches!(after.first(), Some(b) if *b == b'>' || b.is_ascii_whitespace()) {
+ continue;
+ }
+ let close = after.iter().position(|&b| b == b'>')?;
+ let body = after.get(close + 1..)?;
+ let end = body.iter().position(|&b| b == b'<')?;
+ let text = std::str::from_utf8(body.get(..end)?).ok()?.trim();
+ return (!text.is_empty()).then(|| text.to_owned());
+ }
+ None
+}
+
#[cfg(test)]
mod tests {
#[test]
@@ -2616,6 +2673,49 @@ trailer << /Root 1 0 R >>
assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]);
}
+ #[test]
+ fn xml_element_text_reads_one_element_as_designer_writes_it() {
+ let text = |xml: &str| xml_element_text(xml.as_bytes(), b"dynamicRender");
+ assert_eq!(text("forbidden").as_deref(), Some("forbidden"));
+ assert_eq!(text(" required ").as_deref(), Some("required"));
+ // A longer name, a self-closing or empty element, and no element at all give nothing.
+ assert_eq!(text("forbidden"), None);
+ assert_eq!(text(""), None);
+ assert_eq!(text(""), None);
+ assert_eq!(text(""), None);
+ // Cut short anywhere: no panic, nothing read.
+ let whole = "forbidden";
+ for end in 0..whole.len() {
+ let _ = text(&whole[..end]);
+ }
+ assert_eq!(text("forbidden"), None);
+ }
+
+ /// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were
+ /// replaced by a layout on open, even when its configuration says it is a static form.
+ #[test]
+ fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() {
+ let xfa_of = |config: &str, catalog_extra: &str| {
+ let bodies = [
+ format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
+ "<< /Type /Page /Parent 2 0 R >>".to_string(),
+ format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()),
+ "<< /Length 11 >>\nstream\n\nendstream".to_string(),
+ ];
+ inspect(Arc::new(pdf_of(&bodies)), None).expect("opens").xfa
+ };
+ let config = |value: &str| {
+ format!(
+ r#"{value}"#
+ )
+ };
+ assert_eq!(xfa_of(&config("forbidden"), ""), Some(Xfa::Static));
+ assert_eq!(xfa_of(&config("required"), ""), Some(Xfa::Dynamic));
+ assert_eq!(xfa_of("", ""), Some(Xfa::Dynamic), "no field and no word from the config: dynamic, as before");
+ assert_eq!(xfa_of(&config("forbidden"), "/NeedsRendering true"), Some(Xfa::Dynamic), "/NeedsRendering still wins");
+ }
+
/// A file whose objects are `bodies` in order (object 1 first), with a cross-reference table.
fn pdf_of(bodies: &[String]) -> Vec {
let mut out = b"%PDF-1.7\n".to_vec();
diff --git a/crates/xfa/README.md b/crates/xfa/README.md
index 5d494d7b4..310a9b64a 100644
--- a/crates/xfa/README.md
+++ b/crates/xfa/README.md
@@ -117,7 +117,9 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
engine's own limits end it, and the document's scripts are turned off. Memory a script
allocates is not capped (the same holds for AcroForm JavaScript).
- **Static XFA forms** (`/NeedsRendering` absent, AcroForm fields present) keep their AcroForm;
- only their data is read and written.
+ only their data is read and written. A form with no fields is also static when its `config`
+ packet says `forbidden` (Designer's static PDF form): its pages
+ are the form and are not laid out again (#3180). Without that, no fields means dynamic.
- **Flattening and converting.** `strip` takes the XFA out of a document: the packets,
`/NeedsRendering`, the layout mark, the overrides and the private keys on the fields. The
engine calls it after flattening an XFA form's fields (so Adobe's viewers show the baked