fix(forms): an XFA form whose config forbids dynamic rendering is static (#3180)

An XFA form with no AcroForm fields was always classified as dynamic, so
opening it laid the template out into new pages and appended them as a
revision: the pages the user saw, and any later save, were PdfCraft's
layout rather than the file's own pages, with nothing saying so. Adobe
Designer writes <dynamicRender>forbidden</dynamicRender> in the config
packet of a static PDF form; such a form is now static and its pages are
kept. /NeedsRendering still makes a form dynamic, and with no word from
the config a form without fields stays dynamic as before.

The config packet is found in an /XFA array or a whole-XDP stream, read
with a 16 MiB decode cap, and scanned for the one element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410
2026-10-18 15:07:38 +08:00
co-authored by Claude Opus 5.5
parent d8b2705825
commit fe3e07c244
3 changed files with 141 additions and 5 deletions
+34
View File
@@ -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#"<config xmlns="http://www.xfa.org/schema/xci/3.0/"><acrobat><acrobat7><dynamicRender>forbidden</dynamicRender></acrobat7></acrobat></config>"#;
let template = pdfcraft_xfa::fixtures::template(1);
let with_config = template.replacen("<template ", &format!("{config}\n<template "), 1);
assert_ne!(with_config, template, "the config packet went in");
// The fixture shell without /NeedsRendering (blanked in place, so the offsets stay right).
let shell = |xdp: &str| {
let bytes = pdfcraft_xfa::fixtures::shell(xdp);
let at = bytes.windows(20).position(|w| w == b"/NeedsRendering true").expect("the fixture sets it");
let mut bytes = bytes;
bytes.splice(at..at + 20, *b" ");
Arc::new(bytes)
};
let mut s = Session::new().with_clock(|| 1_700_000_000);
let original = shell(&with_config);
let id = s.open("static.pdf", None, original.clone(), None).expect("opens");
let doc = s.get(id).unwrap();
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static));
assert!(doc.xfa.is_none(), "nothing was laid out");
assert_eq!(doc.bytes[..], original[..], "no revision was appended on open");
assert_eq!(doc.info.pages.len(), 1);
// The same form without the config is still taken to be dynamic: laid out from the template
// into an appended revision.
let plain = shell(&template);
let id = s.open("dynamic.pdf", None, plain.clone(), None).expect("opens");
let doc = s.get(id).unwrap();
assert!(doc.xfa.is_some(), "laid out from the template");
assert!(doc.bytes.len() > 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("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"));
+104 -4
View File
@@ -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
// `<dynamicRender>forbidden</dynamicRender>` 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<String> {
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 `<name>` 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<String> {
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 `<name/>` 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("<acrobat7><dynamicRender\n>forbidden</dynamicRender\n></acrobat7>").as_deref(), Some("forbidden"));
assert_eq!(text("<a><xfa:dynamicRender> required </xfa:dynamicRender></a>").as_deref(), Some("required"));
// A longer name, a self-closing or empty element, and no element at all give nothing.
assert_eq!(text("<dynamicRenderX>forbidden</dynamicRenderX>"), None);
assert_eq!(text("<dynamicRender/>"), None);
assert_eq!(text("<dynamicRender></dynamicRender>"), None);
assert_eq!(text("<config><present/></config>"), None);
// Cut short anywhere: no panic, nothing read.
let whole = "<dynamicRender>forbidden</dynamicRender>";
for end in 0..whole.len() {
let _ = text(&whole[..end]);
}
assert_eq!(text("<dynamicRender>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<template/>\nendstream".to_string(),
];
inspect(Arc::new(pdf_of(&bodies)), None).expect("opens").xfa
};
let config = |value: &str| {
format!(
r#"<config xmlns="http://www.xfa.org/schema/xci/3.0/"><acrobat><acrobat7><dynamicRender>{value}</dynamicRender></acrobat7></acrobat></config>"#
)
};
assert_eq!(xfa_of(&config("forbidden"), ""), Some(Xfa::Static));
assert_eq!(xfa_of(&config("required"), ""), Some(Xfa::Dynamic));
assert_eq!(xfa_of("<config/>", ""), 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<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
+3 -1
View File
@@ -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 `<dynamicRender>forbidden</dynamicRender>` (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