XFA: flatten a form into a plain PDF, or convert it to a static form (#1948)

Flattening the fields of a laid-out XFA form used to leave the XFA in
the file: Adobe's viewers then laid the form out again from the
template over the baked pages, so the "flattened" PDF was still a live
form there and a plain one everywhere else.

pdfcraft_xfa::strip takes the XFA out of a document: the packets
(template, data and all), /NeedsRendering on the catalog and the
AcroForm, the layout mark, the overrides, and the private keys on
every field down the tree. The engine calls it after Flatten form
fields on a document with XFA packets, so the result is a plain PDF
every viewer shows the same, and for the new Edit::XfaToStatic
("Convert to a static form"; tool xfa_to_static), which keeps the
laid-out pages and the fields with their values as an ordinary
AcroForm that no viewer lays out again and whose scripts no longer
run. Both undoable: the engine's post-edit refresh re-reads the
layout mark to drop or restore its XFA state and the cached template.

What the packets held beyond the fields is reported (packets by
name, data no field showed, buttons left inert, the layout's own
warnings, Reader usage rights) as document warnings, and a dynamic
form that was never laid out is refused rather than left with
nothing. Applying redactions shares the same strip. Closes the last
planned XFA parity entry (form.xfa-flatten shipped).
This commit is contained in:
ysrdevs
2026-10-15 02:32:04 -04:00
committed by GitHub
parent 9f0ed4ba86
commit e8d1980ded
13 changed files with 596 additions and 45 deletions
Generated
+1
View File
@@ -4218,6 +4218,7 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-model",
"pdfcraft-xfa",
"pdfcraft-testkit",
"thiserror 2.0.21",
]
+1
View File
@@ -354,6 +354,7 @@ impl Automation {
}
"page_set_box" => self.page_set_box(&a)?,
"doc_create" => self.doc_create(&a)?,
"xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?,
"doc_flatten" => {
let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true));
if !comments && !fields {
+8 -1
View File
@@ -1258,9 +1258,16 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "path"],
)),
t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Undoable until saved.")
t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Flattening the fields of an XFA form also takes the XFA out (its template, data and scripts), so Adobe's viewers show the baked pages rather than laying the form out again. Undoable until saved.")
.destructive()
.with(schema(json!({ "doc": doc(), "comments": { "type": "boolean", "description": "Default true." }, "fields": { "type": "boolean", "description": "Default true." } }), &["doc"])),
t(
"xfa_to_static",
"Convert an XFA form to a static form",
"Make an XFA form an ordinary PDF form: the XFA packets (template, data, scripts) and PdfCraft's private keys go; the pages as laid out and the fields with their values stay, so every viewer shows the same form and none lays it out again. Its scripts no longer run. Undoable until saved.",
)
.destructive()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])),
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
t("command_list", "List commands", "Every registered PdfCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")
+27
View File
@@ -4751,6 +4751,33 @@ fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
eprintln!("{} calculate scripts: open {open_took:?}, recalculation after a change {change_took:?}", sections * rows + sections);
}
#[test]
fn xfa_forms_flatten_and_convert_to_static_through_tools() {
let dir = workdir("xfa-flat");
let data = "<form><page1><country>JP</country></page1></form>";
std::fs::write(dir.join("fields.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))).unwrap();
let mut a = auto(&dir);
// Convert to static: the fields stay, the XFA goes.
let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"], "dynamic");
let before = ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len();
ok(&mut a, "xfa_to_static", json!({ "doc": doc }));
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
assert!(info["xfa"].is_null(), "{info}");
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), before);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "static.pdf" }));
let again = ok(&mut a, "doc_open", json!({ "path": "static.pdf" }))["doc"].as_u64().unwrap();
assert!(ok(&mut a, "doc_info", json!({ "doc": again }))["xfa"].is_null());
assert!(a.call("xfa_to_static", &json!({ "doc": again })).is_err(), "not an XFA form");
// Flatten the fields: a plain PDF with the values as page content.
let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_flatten", json!({ "doc": doc, "comments": false, "fields": true }));
assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"].is_null());
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), 0);
let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
assert!(text.to_string().contains("Japan"), "{text}");
}
#[test]
fn xfa_data_files_export_and_import_through_tools() {
let dir = workdir("xfa-data");
+92 -12
View File
@@ -1551,6 +1551,11 @@ pub enum Edit {
},
/// Sanitize Document: every category, then a full rewrite on save.
Sanitize,
/// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and
/// PdfCraft's private keys go, the pages as laid out and the fields with their values
/// stay, so every viewer shows the same form and none lays it out again. Its scripts no
/// longer run.
XfaToStatic,
/// Flatten comments and/or form fields on every page into page content.
Flatten {
comments: bool,
@@ -1885,6 +1890,7 @@ impl Edit {
Edit::RemoveLinks { .. } => Label::of("Remove all links"),
Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()),
Edit::Sanitize => Label::of("Sanitize document"),
Edit::XfaToStatic => Label::of("Convert to a static form"),
Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"),
Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"),
Edit::Flatten { .. } => Label::of("Flatten"),
@@ -2021,6 +2027,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::RemoveLinks { .. }
| Edit::AddLinks { .. }
| Edit::Sanitize
| Edit::XfaToStatic
| Edit::SetFieldProps { .. }
| Edit::DeleteField { .. }
| Edit::DuplicateField { .. }
@@ -2065,6 +2072,8 @@ struct EditCtx {
xfa_datasets: Option<pdfcraft_cos::ObjRef>,
/// A form script ran too long and was abandoned: the document's scripts go off.
xfa_ran_away: bool,
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
xfa_removed: Vec<String>,
/// Pages in the document, for `xfa.layout.pageCount()`.
pages: usize,
date: Option<String>,
@@ -2094,6 +2103,7 @@ impl EditCtx {
xfa_out: Default::default(),
xfa_datasets: None,
xfa_ran_away: false,
xfa_removed: Vec::new(),
pages: 0,
date: now.map(pdfcraft_cos::pdf_date),
today: (1970, 1, 1),
@@ -2476,8 +2486,26 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_redact::sanitize::sanitize(doc)?;
}
Edit::Flatten { comments, fields } => {
// An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers
// would lay the form out again from its template over the baked pages. A dynamic
// form that was never laid out keeps its XFA: without it only placeholders remain.
let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten();
let n = pdfcraft_model::pages(doc).len();
pdfcraft_edit::flatten(doc, &(0..n).collect::<Vec<_>>(), *comments, *fields)?;
match shape {
Some(shape) if shape.strippable() => strip_xfa(doc, cx)?,
Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()),
None => {}
}
}
Edit::XfaToStatic => {
let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else {
return Err(EditError::Invalid("this document is not an XFA form".into()));
};
if !shape.strippable() {
return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into()));
}
strip_xfa(doc, cx)?;
}
Edit::FlattenFillSign => {
let n = pdfcraft_model::pages(doc).len();
@@ -2863,6 +2891,62 @@ fn decode_data_file(bytes: &[u8]) -> Option<Result<String, String>> {
}
}
/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed,
/// buttons that did nothing but run scripts, and fields whose value differs from the form's
/// data (data PdfCraft could not read into them, now lost).
fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> {
let fields = pdfcraft_forms::fields(doc);
let data = xfa_field_data(doc);
let differ = pdfcraft_xfa::read_values(doc, &data)
.into_iter()
.filter(|(name, value)| {
let Some(f) = fields.iter().find(|f| f.name == *name) else { return false };
match value {
pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(),
pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on,
pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(),
pdfcraft_xfa::FieldData::None => false,
}
})
.count();
let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?;
cx.xfa_removed.extend(r.notes());
if differ > 0 {
cx.xfa_removed.push(format!(
"{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone"
));
}
Ok(())
}
/// Keep the document's XFA state in step with its file after an edit: a script may have laid
/// the form out again (rows added, subforms shown), an edit may have taken the XFA out
/// (flattened, or made static), or an undo put it back.
fn sync_xfa_state(doc: &mut Document) {
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return };
match pdfcraft_xfa::existing_layout(cos) {
Some(report) => {
if doc.xfa.is_none() {
// Back after an undo: the XFA's own warnings apply again, the strip's don't.
doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX));
if doc.xfa_template.is_none() && !doc.xfa_scripts_off {
doc.xfa_template = xfa::template(cos);
}
}
doc.xfa = Some(report);
}
None => {
doc.xfa = None;
doc.xfa_template = None;
// Only what the strip said stays (the XFA's own warnings no longer apply).
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
}
}
}
/// How the warnings of a strip start, so they can be told from the XFA's own.
const XFA_REMOVED_PREFIX: &str = "XFA removed: ";
/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document),
/// rather than XFDF, Acrobat's `<fields>` XML or a table? By its root element.
fn is_xfa_data_file(text: &str) -> bool {
@@ -3492,6 +3576,12 @@ impl Session {
doc.dirty = true;
doc.generation += 1;
doc.edit_notices = notices;
if !cx.xfa_removed.is_empty() {
let removed: Vec<String> = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect();
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
note_warnings(&mut doc.xfa_warnings, &removed);
note_warnings(&mut doc.info.warnings, &removed);
}
note_warnings(&mut doc.xfa_warnings, &xfa_notes);
let notes = doc.xfa_warnings.clone();
note_warnings(&mut doc.info.warnings, &notes);
@@ -3660,13 +3750,8 @@ impl Session {
/// appearances are kept: content edits leave every annotation dictionary alone.
fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> {
Self::release_working(doc);
sync_xfa_state(doc);
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
&& let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos)
{
doc.xfa = Some(report);
}
let bytes = if editor.cos.is_modified() {
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
} else {
@@ -3722,13 +3807,8 @@ impl Session {
editor.appearances.clear();
}
Self::release_working(doc);
sync_xfa_state(doc);
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
&& let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos)
{
doc.xfa = Some(report);
}
let bytes = if editor.cos.is_modified() {
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
} else {
+107
View File
@@ -3918,6 +3918,113 @@ fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() {
);
}
#[test]
fn flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out() {
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed here".into()) }).unwrap();
s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap();
let d = s.get(id).unwrap();
assert!(d.form.is_empty(), "no fields remain");
assert_eq!(d.info.xfa, None, "no longer an XFA form: Adobe's viewers show the baked pages");
assert!(d.xfa.is_none());
let texts = page_texts(&s, id).join("\n");
for want in ["Japan", "typed here", "Country: JP", "12345"] {
assert!(texts.contains(want), "{want} is page content now: {texts}");
}
// Saved and reopened: a plain PDF, the same page content, nothing of the XFA left.
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none());
let id2 = s.open("flat.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!((d2.info.xfa, d2.form.len()), (None, 0));
assert!(page_texts(&s, id2).join("\n").contains("typed here"));
// Undone, the form is a laid-out XFA form again.
s.undo(id).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
assert_eq!(d.form.iter().find(|f| f.name == "other").unwrap().value, vec!["typed here".to_string()]);
// Flattening comments alone leaves the XFA form as it is.
s.apply(id, Edit::Flatten { comments: true, fields: false }).unwrap();
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
}
#[test]
fn a_dynamic_xfa_form_that_was_never_laid_out_keeps_its_xfa_and_the_user_is_told_what_went() {
// A template that can't be read: the form keeps its placeholder page and its XFA.
let broken = "<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form\"</template></xdp:xdp>";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("broken.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(broken)), None).expect("opens");
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
assert!(s.get(id).unwrap().xfa.is_none(), "not laid out");
assert!(s.apply(id, Edit::XfaToStatic).is_err(), "nothing to keep");
s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "the XFA stays: Adobe can still render the form");
assert!(d.info.warnings.iter().any(|w| w.contains("The XFA was kept")), "{:?}", d.info.warnings);
// A laid-out form with data no field holds: converting says what went with the XFA, and
// the note stays on the document, while the XFA's own warnings go; undo brings those back.
let data = "<form><page1><country>JP</country><nobody>holds this</nobody></page1></form>";
let id = s.open("fields.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))), None).unwrap();
let before: Vec<String> = s.get(id).unwrap().info.warnings.clone();
assert!(before.iter().any(|w| w.contains("scripts changed it on opening")), "an XFA-only warning: {before:?}");
s.apply(id, Edit::XfaToStatic).unwrap();
let w = s.get(id).unwrap().info.warnings.clone();
assert!(w.iter().any(|n| n.starts_with("XFA removed: The XFA was taken out") && n.contains("template, datasets")), "{w:?}");
assert!(w.iter().any(|n| n.contains("1 value(s) the form's XFA data held that no field showed")), "{w:?}");
assert!(w.iter().any(|n| n.contains("1 image field(s) do nothing now")), "{w:?}");
assert!(!w.iter().any(|n| n.contains("scripts changed it on opening")), "the XFA's own warning is gone: {w:?}");
let js = s.take_js_output(id);
assert!(js.errors.is_empty(), "{js:?}");
s.undo(id).unwrap();
let w = s.get(id).unwrap().info.warnings.clone();
assert!(!w.iter().any(|n| n.starts_with("XFA removed")), "{w:?}");
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
}
#[test]
fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, id: DocId, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
assert_eq!(value(&s, id, "total"), vec!["10".to_string()], "the scripts ran on open");
let fields_before = s.get(id).unwrap().form.len();
s.apply(id, Edit::XfaToStatic).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.info.xfa, None);
assert_eq!(d.form.len(), fields_before, "the fields stay");
assert_eq!(value(&s, id, "qty"), vec!["2".to_string()]);
assert_eq!(value(&s, id, "total"), vec!["10".to_string()]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Convert to a static form"));
// The form is an ordinary one now: a change no longer runs the XFA calculation.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("7".into()) }).unwrap();
assert_eq!(value(&s, id, "total"), vec!["10".to_string()]);
assert!(s.take_js_output(id).is_empty());
// Saved: no XFA, no private keys on the fields, the pages as laid out.
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none());
assert!(pdfcraft_xfa::fields_by_som(&cos).is_empty(), "no /PCSom left");
let id2 = s.open("static.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!(d2.info.xfa, None);
assert_eq!(value(&s, id2, "qty"), vec!["7".to_string()]);
assert_eq!(d2.info.pages.len(), 1);
// Not an XFA form: refused.
assert!(s.apply(id2, Edit::XfaToStatic).is_err());
// Undone, the XFA form is back and its scripts run again.
s.undo(id).unwrap();
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert_eq!(value(&s, id, "total"), vec!["15".to_string()]);
}
#[test]
fn dynamic_xfa_forms_run_formcalc_scripts() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
+1
View File
@@ -16,6 +16,7 @@ pdfcraft-model = { workspace = true }
pdfcraft-forms = { workspace = true }
pdfcraft-edit = { workspace = true }
pdfcraft-annot = { workspace = true }
pdfcraft-xfa = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
+6 -26
View File
@@ -470,32 +470,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
// 3b. An XFA form mirrors its field values in the /XFA datasets packet, and a relayout
// rebuilds fields and pages from that packet, so deleting the AcroForm field alone leaves the
// redacted value in the file and lets a later relayout put it back. Once redactions are
// applied the form stops being XFA: drop /AcroForm /XFA (template and datasets), the catalog's
// /NeedsRendering, and PdfCraft's own layout mark (pdfcraft_xfa::LAYOUT_KEY), leaving a plain
// already-laid-out AcroForm that neither the saved file nor a relayout can restore from.
if let Some(root) = doc.root() {
let strip_xfa = |d: &mut Dict| {
d.remove(b"XFA");
d.remove(b"PCXfaLayout");
};
match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) {
Some(Object::Ref(af)) if doc.get(af).as_dict().is_some_and(|d| d.contains(b"XFA")) => {
doc.update_dict(af, strip_xfa)?;
doc.update_dict(root, |d| {
d.remove(b"NeedsRendering");
})?;
report.xfa_removed = true;
}
Some(Object::Dict(mut af)) if af.contains(b"XFA") => {
strip_xfa(&mut af);
doc.update_dict(root, |d| {
d.set(b"AcroForm".to_vec(), Object::Dict(af));
d.remove(b"NeedsRendering");
})?;
report.xfa_removed = true;
}
_ => {}
}
// applied the form stops being XFA: the packets, /NeedsRendering, the layout mark and the
// rest go (pdfcraft_xfa::strip), leaving a plain already-laid-out AcroForm that neither the
// saved file nor a relayout can restore from.
if pdfcraft_xfa::xfa_shape(doc).is_some() {
let r = pdfcraft_xfa::strip(doc).map_err(|e| pdfcraft_edit::EditError::Invalid(e.to_string()))?;
report.xfa_removed = !r.packets.is_empty();
}
// 4. The boxes.
+6
View File
@@ -118,6 +118,12 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
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.
- **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
pages instead of laying the form out again from the template) and for "convert to a static
form" (`xfa_to_static`), which keeps the laid-out pages and the fields with their values as
an ordinary AcroForm that no viewer lays out again and whose scripts no longer run.
- **Field kinds.** Choice lists become combo boxes (editable with `textEntry`) or list boxes
(`open="always"`, multi-select with `open="multiSelect"`), with the shown items and the saved
values of `<items save="1">` as the options; the data holds saved values, several on
+1 -1
View File
@@ -98,7 +98,7 @@ pub use data::{
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout};
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, parse};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, existing_layout};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree,
has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,
+244
View File
@@ -777,6 +777,250 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
Ok(Written { pages: count, fields: field_count, warnings })
}
/// What [`strip`] took out, for the user to be told (the packets held more than the fields).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Stripped {
/// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …).
pub packets: Vec<String>,
/// Data values in the datasets no field held (what the form's data had that its fields
/// didn't show); `None` when the template could not be read to tell.
pub unbound_data: Option<usize>,
/// Buttons whose click ran an XFA script, and image fields: they do nothing from now on.
pub script_buttons: usize,
pub image_fields: usize,
/// What the layout left out (pictures, rows past the cap), now for good.
pub layout_warnings: Vec<String>,
/// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway.
pub usage_rights: bool,
/// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …).
pub field_keys: usize,
}
impl Stripped {
/// What was dropped, as lines for the user (empty when nothing worth saying).
pub fn notes(&self) -> Vec<String> {
let mut out = Vec::new();
if !self.packets.is_empty() {
out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", ")));
}
match self.unbound_data {
Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")),
None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()),
_ => {}
}
if self.script_buttons + self.image_fields > 0 {
out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields));
}
for w in &self.layout_warnings {
out.push(format!("Kept as laid out: {w}"));
}
if self.usage_rights {
out.push("Adobe Reader extended features were removed (changing the form voids them)".into());
}
out
}
}
/// What a document's XFA entry says, from its dictionaries alone (no packet decoded).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct XfaShape {
/// `/NeedsRendering true`: the form expects to be laid out from the template.
pub needs_rendering: bool,
/// The AcroForm has fields of its own.
pub has_fields: bool,
/// PdfCraft laid the form out (the layout mark is there).
pub laid_out: bool,
}
impl XfaShape {
/// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out
/// (with their fields), or a static form's own pages and fields. A dynamic form that was
/// never laid out has only placeholder pages: stripping it would leave nothing.
pub fn strippable(self) -> bool {
self.laid_out || (!self.needs_rendering && self.has_fields)
}
}
/// The document's XFA entry, if any, without decoding a packet.
pub fn xfa_shape(doc: &Document) -> Option<XfaShape> {
let root = doc.root()?;
let catalog = doc.get(root);
let catalog = catalog.as_dict()?;
let acro = doc.resolve(catalog.get(b"AcroForm")?);
let acro = acro.as_dict()?;
acro.get(b"XFA")?;
let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true)));
let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering"));
let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false);
Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) })
}
/// Most field dictionaries walked for their private keys.
const MAX_STRIP_FIELDS: usize = 1_000_000;
/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out
/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and
/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and
/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left)
/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no
/// longer lay out again. What the packets held beyond the fields is counted in the report,
/// so the user can be told; the caller decides whether the form is one to strip
/// ([`XfaShape::strippable`]). A document without XFA is left as it is.
pub fn strip(doc: &mut Document) -> Result<Stripped, XfaError> {
let mut out = Stripped::default();
let Some(root) = doc.root() else { return Ok(out) };
let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) };
let acro_obj = catalog.get(b"AcroForm").cloned();
let Some(acro_obj) = acro_obj else { return Ok(out) };
let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) };
let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) };
// What is about to go, while it can still be read.
out.unbound_data = unbound_data(doc);
out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default();
let mut streams: Vec<ObjRef> = Vec::new();
match &*doc.resolve(&xfa) {
Object::Array(items) => {
let mut i = 0;
while i < items.len() {
let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text());
let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) };
if let Some(n) = &name {
out.packets.push(n.clone());
}
if let Some(r) = stream.and_then(Object::as_ref) {
streams.push(r);
}
i += if name.is_some() { 2 } else { 1 };
}
}
Object::Stream(s) => {
if let Some(r) = xfa.as_ref() {
streams.push(r);
}
// One XDP: the packets it holds are the envelope's children.
let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default();
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 };
if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH
&& let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts)
{
let root = parsed.root_element();
if root.tag_name().name() == "xdp" {
out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32));
} else {
out.packets.push(root.tag_name().name().to_string());
}
}
if out.packets.is_empty() {
out.packets.push("xdp".into());
}
}
_ => {}
}
acro.remove(b"XFA");
for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] {
acro.remove(key);
}
// The private keys on every field and widget, down the tree (each dictionary once).
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
let mut seen = std::collections::HashSet::new();
let mut todo: Vec<ObjRef> = Vec::new();
for r in fields.iter().filter_map(Object::as_ref) {
if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS {
todo.push(r);
}
}
while let Some(r) = todo.pop() {
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
if d.contains(CLICK_KEY) {
out.script_buttons += 1;
}
if d.contains(IMAGE_FIELD_KEY) {
out.image_fields += 1;
}
let private: Vec<Vec<u8>> =
[SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect();
if !private.is_empty() {
out.field_keys += private.len();
doc.update_dict(r, |d| {
for k in &private {
d.remove(k);
}
})?;
}
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
for k in kids.iter().filter_map(Object::as_ref) {
if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS {
todo.push(k);
}
}
}
}
match acro_obj.as_ref() {
Some(r) => doc.set(r, Object::Dict(acro)),
None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)),
}
catalog.remove(b"NeedsRendering");
// Reader usage rights are void once the form changed: Reader says so on opening the file
// and drops them, so they go now, with a note.
if let Some(perms) = catalog.get(b"Perms").map(|p| doc.resolve(p))
&& let Some(mut perms) = perms.as_dict().cloned()
&& (perms.contains(b"UR3") || perms.contains(b"UR"))
{
perms.remove(b"UR3");
perms.remove(b"UR");
out.usage_rights = true;
if perms.is_empty() {
catalog.remove(b"Perms");
} else {
catalog.set(b"Perms".to_vec(), Object::Dict(perms));
}
}
doc.set(root, Object::Dict(catalog));
// The packet streams are referenced from nowhere else: freed, so an incremental save
// doesn't carry an unreachable copy of the data.
for r in streams {
doc.free(r);
}
Ok(out)
}
/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose
/// data path no field binds to). `None` when the template can't be read.
fn unbound_data(doc: &Document) -> Option<usize> {
let data = crate::script::data_of(doc)?;
let tpl = crate::script::template_of(doc).ok()?;
let bound: std::collections::HashSet<crate::data::DataPath> =
crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect();
fn walk(
n: &crate::data::DataNode,
path: &mut crate::data::DataPath,
bound: &std::collections::HashSet<crate::data::DataPath>,
count: &mut usize,
depth: usize,
) {
if depth > 64 {
return;
}
let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for c in &n.children {
let idx = seen.entry(c.name.as_str()).or_insert(0);
path.push((c.name.clone(), *idx));
*idx += 1;
if c.children.is_empty() {
if !c.text.trim().is_empty() && !bound.contains(path) {
*count += 1;
}
} else {
walk(c, path, bound, count, depth + 1);
}
path.pop();
}
}
let mut count = 0;
walk(&data, &mut Vec::new(), &bound, &mut count, 0);
Some(count)
}
/// The layout a document already carries from an earlier pass, if any.
pub fn existing_layout(doc: &Document) -> Option<crate::Report> {
let root = doc.root()?;
+97
View File
@@ -1836,3 +1836,100 @@ fn an_anchor_type_places_the_named_point_of_the_box_at_its_x_and_y() {
let field = widgets(page).into_iter().find(|w| w.name == "ref").unwrap();
assert!((field.rect.x - 6.0 * 72.0).abs() < 0.01 && (field.rect.y - 72.0).abs() < 0.01, "{:?}", field.rect);
}
#[test]
fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() {
use crate::pdf::strip;
let mut doc = Document::open(Arc::new(shell(&fields_template("<form><page1><country>JP</country></page1></form>")))).unwrap();
render_into(&mut doc).unwrap();
assert!(crate::read_packets(&doc).unwrap().is_some());
// Reader usage rights on the catalog go too (changing the form voids them).
let root = doc.root().unwrap();
let mut ur = pdfcraft_cos::Dict::new();
ur.set(b"UR3".to_vec(), Object::Dict(pdfcraft_cos::Dict::new()));
doc.update_dict(root, |d| d.set(b"Perms".to_vec(), Object::Dict(ur))).unwrap();
let r = strip(&mut doc).unwrap();
assert_eq!(r.packets, vec!["template".to_string(), "datasets".to_string()]);
assert_eq!(r.unbound_data, Some(0));
assert_eq!((r.script_buttons, r.image_fields), (0, 1), "{r:?}");
assert!(r.usage_rights);
assert!(r.field_keys >= 7, "{r:?}");
assert!(
r.notes().iter().any(|n| n.contains("template, datasets")) && r.notes().iter().any(|n| n.contains("extended features")),
"{:?}",
r.notes()
);
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(crate::read_packets(&doc).unwrap().is_none(), "no XFA entry");
assert!(!is_dynamic(&doc));
assert!(crate::existing_layout(&doc).is_none(), "the layout mark is gone");
let root = doc.get(doc.root().unwrap());
let catalog = root.as_dict().unwrap();
assert!(!catalog.contains(b"NeedsRendering") && !catalog.contains(b"Perms"));
let acro = doc.resolve(catalog.get(b"AcroForm").unwrap());
let acro = acro.as_dict().unwrap();
assert!(!acro.contains(b"XFA") && !acro.contains(b"PCXfaLayout") && !acro.contains(b"PCXfaOverrides"));
// The fields stay, with their values, and without the private keys.
let fields = pdfcraft_forms_free_fields(&doc);
assert_eq!(fields.len(), 7);
let country = &fields.iter().find(|(n, _)| n == "country").unwrap().1;
assert_eq!(country.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("JP"));
for (name, d) in &fields {
for k in [b"PCSom".as_slice(), b"PCXfaClick", b"PCXfaImage", b"PCItems"] {
assert!(!d.contains(k), "{name} still has {}", String::from_utf8_lossy(k));
}
}
// The pages are untouched.
let pages = doc.resolve(catalog.get(b"Pages").unwrap());
assert_eq!(pages.as_dict().unwrap().int(b"Count"), Some(1));
// Twice is nothing: a document without XFA is left as it is.
let mut doc = doc;
let r = strip(&mut doc).unwrap();
assert_eq!(r, crate::Stripped::default());
assert!(!doc.is_modified());
// Data no field holds is counted; a form whose click buttons ran scripts too; the shape
// check needs no packet decoded and tells a never-laid-out dynamic form from one to strip.
let mut doc = Document::open(Arc::new(shell(&scripted_template().replace("</template>", "</template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><page1><qty>3</qty><extra>kept nowhere</extra><more2>x</more2></page1></form></xfa:data></xfa:datasets>")))).unwrap();
let before = crate::xfa_shape(&doc).unwrap();
assert_eq!(before, crate::XfaShape { needs_rendering: true, has_fields: false, laid_out: false });
assert!(!before.strippable(), "nothing laid out yet: stripping would leave the placeholder page");
render_into(&mut doc).unwrap();
assert!(crate::xfa_shape(&doc).unwrap().strippable());
let r = strip(&mut doc).unwrap();
assert_eq!(r.unbound_data, Some(2), "{r:?}");
assert_eq!(r.script_buttons, 4, "addRow, removeRow, hello, legacy: {r:?}");
assert!(crate::xfa_shape(&doc).is_none());
// A field tree whose dictionaries share one Kids array (every kid listing every other) is
// walked once per dictionary, not once per listing.
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
render_into(&mut doc).unwrap();
let root = doc.root().unwrap();
let acro_ref = doc.get(root).as_dict().unwrap().get(b"AcroForm").and_then(Object::as_ref);
let fields: Vec<Object> = (0..2000).map(|_| Object::Ref(doc.add(Object::Dict(pdfcraft_cos::Dict::new())))).collect();
let kids = doc.add(Object::Array(fields.clone()));
for f in &fields {
if let Some(r) = f.as_ref() {
doc.update_dict(r, |d| {
d.set(b"Kids".to_vec(), Object::Ref(kids));
d.set(b"PCSom".to_vec(), Object::String(pdfcraft_cos::PdfString::text("x")));
})
.unwrap();
}
}
let update = |d: &mut pdfcraft_cos::Dict| d.set(b"Fields".to_vec(), Object::Array(fields.clone()));
match acro_ref {
Some(r) => doc.update_dict(r, update).unwrap(),
None => doc
.update_dict(root, |c| {
let mut a = c.get(b"AcroForm").and_then(|a| a.as_dict().cloned()).unwrap_or_default();
update(&mut a);
c.set(b"AcroForm".to_vec(), Object::Dict(a));
})
.unwrap(),
}
let started = std::time::Instant::now();
let r = strip(&mut doc).unwrap();
assert_eq!(r.field_keys, 2000);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
}
+5 -5
View File
@@ -5241,11 +5241,11 @@ title = "Flatten XFA forms"
area = "F"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["form.flatten"]
tools = ["doc_flatten", "xfa_to_static"]
evidence = ["crates/xfa/src/tests.rs::stripping_takes_the_xfa_out_and_leaves_an_ordinary_document", "crates/engine/src/tests.rs::flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out", "crates/engine/src/tests.rs::an_xfa_form_converts_to_a_static_form_that_keeps_its_fields", "crates/automation/tests/automation.rs::xfa_forms_flatten_and_convert_to_static_through_tools"]
notes = "Flatten form fields on a laid-out XFA form bakes the fields into the pages and takes the XFA out (packets, /NeedsRendering, PdfCraft's private keys), so every viewer, Adobe's included, shows the baked pages rather than laying the form out again from the template. xfa_to_static (tool and engine edit) keeps the laid-out pages and the fields with their values as an ordinary AcroForm whose scripts no longer run. Both undoable until saved. Missing: a menu entry for the conversion (the tool and the MCP server have it); flattening keeps the layout as PdfCraft laid it out, not as Acrobat would (form.xfa-dynamic)"
[[feature]]
id = "form.fill-sign-text"