mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d8b2705825
commit
fe3e07c244
@@ -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>"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user