diff --git a/Cargo.lock b/Cargo.lock index d7dc06ab5a..59fbc32dbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4218,6 +4218,7 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-xfa", "pdfcraft-testkit", "thiserror 2.0.21", ] diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 7b8e166d81..12c5d094dd 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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 { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 86436d4d10..9a37863304 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1258,9 +1258,16 @@ pub fn tools() -> Vec { }), &["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.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 8f2318b96a..8ff0a754f2 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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 = "
JP
"; + 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"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b73dbacc5e..42ebe340b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// 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, /// Pages in the document, for `xfa.layout.pageCount()`. pages: usize, date: Option, @@ -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::>(), *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> { } } +/// 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 `` 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 = 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, ¬es); @@ -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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2711d19f77..0677485c91 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 = "
JPEnglish\nSpanish
"; + 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 = "", "
3kept nowherex
")))).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 = (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()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5b9cbe97e2..d2da67304b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"