From 82b709d5163d9dafd95dad57a11afaffc52e6b30 Mon Sep 17 00:00:00 2001 From: ysrdevs <32823553+ysrdevs@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:29:35 -0500 Subject: [PATCH] XFA: import and export data files, and pictures for image fields (#1175) Export Data on an XFA form writes its data as Acrobat does: XML (the data alone, rooted at the form's root data node, its namespaces declared) or an XDP holding the whole datasets and a reference to the PDF (.xdp, a new format). Import Data takes an XDP, a datasets or data element, or a data document whose root is the form's root data node, and makes it the form's whole data, keeping data descriptions; a laid-out form is laid out again from it (rows added and removed) and its calculations run; XFDF, FDF and tables still go the AcroForm way. Undoable. Image fields are now buttons marked /PCXfaImage (no PDF action): the forms layer treats one as an image field, so clicking it (or form_set_image) picks a picture, which is kept in the data as base64 with its xfa:contentType, as Designer saves it, and the laid-out form shows it from there. xfa: replace_data, write_data_image (hostile content types cleaned, 32 MB cap), export_data, import_data (64 MB cap, the parser's node limit, self-closing data elements). engine: export routing by document kind, import routing by the file's root element, re-layout and calculation after the data changes outside the fields, the picture's content type from its bytes. UI: XML and XDP in the export dialog of an XFA form, .xdp in the import filter. Tool texts, README and parity (form.xfa-data) updated. Hardened after an adversarial review pass: the file's data is held as a tree and written with the packet's own namespace prefix (never its markup), every packet rewrite is parsed back before it is written, files capped at 16 MB and 64 levels (a tag scan before the recursive parser, also guarding the datasets parser), pictures at 8 MB and PNG/ JPEG/GIF only, the file's shape and root name checked, UTF-16 and ISO-8859-1 decoded, pictures into data groups refused, cleaned names for pictures, static forms empty the fields the file doesn't hold, more rows than the layout cap reported, one datasets stream per edit, and only image fields take pictures into the data. --- crates/automation/src/forms.rs | 3 +- crates/automation/src/tools.rs | 6 +- crates/automation/tests/automation.rs | 33 ++ crates/engine/src/lib.rs | 174 ++++++++++- crates/engine/src/tests.rs | 133 ++++++++ crates/engine/src/xfa.rs | 11 + crates/forms/src/lib.rs | 5 + crates/ui-egui/src/files.rs | 14 +- crates/xfa/README.md | 14 +- crates/xfa/src/data.rs | 429 +++++++++++++++++++++++++- crates/xfa/src/layout.rs | 27 +- crates/xfa/src/lib.rs | 1 + crates/xfa/src/pdf.rs | 11 + crates/xfa/src/tests.rs | 226 +++++++++++++- crates/xfdf/src/lib.rs | 6 +- parity/acrobat-features.toml | 12 +- 16 files changed, 1072 insertions(+), 33 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index e6334489cf..7c60425a8c 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -520,7 +520,8 @@ impl Automation { let id = self.doc(a)?.id; let path = self.resolve(a.str("path")?, true)?; let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); - let format = pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .csv or .txt"))?; + let format = + pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .xdp, .csv or .txt"))?; let (comments, fields) = match a.opt_str("what")?.unwrap_or("all") { "all" => (true, true), "comments" => (true, false), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c712ab3890..0e0f5116ec 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -345,7 +345,7 @@ pub fn tools() -> Vec { }), &["doc", "page", "path", "at"], )), - t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). Undoable.") + t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), t( "form_set_props", @@ -388,10 +388,10 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], )), - t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). what: all (default), comments, fields.") + t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.") .destructive() .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), - t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. Undoable.") + t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") .destructive() diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 5f8a928c3a..0298b396ba 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3534,6 +3534,39 @@ fn exporting_to_word_html_and_rtf() { assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } +#[test] +fn xfa_data_files_export_and_import_through_tools() { + let dir = workdir("xfa-data"); + 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); + let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xdp", "what": "fields" })); + let xml = std::fs::read_to_string(dir.join("data.xml")).unwrap(); + let xdp = std::fs::read_to_string(dir.join("data.xdp")).unwrap(); + assert!(xml.contains("JP"), "{xml}"); + assert!(xdp.contains("JP"), "{xdp}"); + // Edit the exported data and import it back. + std::fs::write(dir.join("edited.xdp"), xdp.replace("JP", "CA")).unwrap(); + let r = ok(&mut a, "doc_import_data", json!({ "doc": doc, "path": "edited.xdp" })); + assert!(r["filled_fields"].as_u64().unwrap() >= 1); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let country = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "country").unwrap(); + assert_eq!(country["value"], json!("CA")); + // A picture for the image field, through form_set_image, lands in the data. + std::fs::write(dir.join("pic.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap(); + ok(&mut a, "form_set_image", json!({ "doc": doc, "field": "photo", "path": "pic.gif" })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "after.xml", "what": "fields" })); + let after = std::fs::read_to_string(dir.join("after.xml")).unwrap(); + assert!(after.contains("R0lGODlh"), "{after}"); + // XDP is for XFA forms only. + std::fs::write(dir.join("plain.pdf"), pdfcraft_xfa::fixtures::static_shell("Ada")).unwrap(); + let plain = ok(&mut a, "doc_open", json!({ "path": "plain.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_data", json!({ "doc": plain, "path": "static.xml", "what": "fields" })); + assert!(std::fs::read_to_string(dir.join("static.xml")).unwrap().contains("Ada"), "a static XFA form exports its data too"); +} + #[test] fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() { let dir = workdir("xfa"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ffc22681a2..218b0779b2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2029,7 +2029,34 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), _ => (1, 1), }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG or GIF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } } Edit::ResetForm { names } => { pdfcraft_forms::reset(doc, names.as_deref())?; @@ -2174,7 +2201,24 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } } Edit::ImportData { bytes, .. } => { - pdfcraft_xfdf::import(doc, bytes)?; + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } } Edit::RemoveHidden { which } => { pdfcraft_redact::sanitize::remove_hidden(doc, which)?; @@ -2508,6 +2552,117 @@ fn note_warnings(list: &mut Vec, new: &[String]) { } } +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// 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 { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), + pdfcraft_xfa::image::Kind::Png => Some("image/png"), + pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + /// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns /// the names of the fields that changed. fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { @@ -3406,11 +3561,20 @@ impl Session { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } guard(|| match format { - DataFormat::Xfdf => pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes(), - DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name), - other => pdfcraft_xfdf::export_data(&cos, other).into_bytes(), + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), }) + .map_err(EditError::Write)? .map_err(EditError::Write) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b043cdd932..75d52e1d89 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2634,6 +2634,139 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]); } +#[test] +fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { + use pdfcraft_xfa::som_to_path; + 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"); + let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}")); + // Export: XML is the datasets document, XDP wraps it; the fields' current values are in it. + s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap(); + let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap(); + assert!(xml.starts_with("CA") && !xml.contains("CA") && xdp.contains("href=\"fields.pdf\""), "{xdp}"); + // Import: the data file's values replace the form's; the calculation runs; undo brings + // the old values back. + let file = "
FRFrench1234
"; + s.apply(id, Edit::ImportData { name: "data.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + assert_eq!(field(&s, "langs").value, vec!["French".to_string()]); + assert_eq!(field(&s, "pin").value, vec!["1234".to_string()]); + assert_eq!(field(&s, "summary").value, vec!["Country: FR".to_string()], "{:?}", s.take_js_output(id)); + s.undo(id).unwrap(); + assert_eq!(field(&s, "country").value, vec!["CA".to_string()]); + s.redo(id).unwrap(); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + // A file in another encoding, and one with a long comment before its root, import too. + let latin1: Vec = + "
Société
".chars().map(|c| c as u32 as u8).collect(); + s.apply(id, Edit::ImportData { name: "l1.xml".into(), bytes: Arc::new(latin1) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["Société".to_string()]); + let commented = format!("
after
", "x".repeat(5000)); + s.apply(id, Edit::ImportData { name: "c.xml".into(), bytes: Arc::new(commented.into_bytes()) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["after".to_string()]); + let utf16: Vec = "
wide
".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + s.apply(id, Edit::ImportData { name: "w.xml".into(), bytes: Arc::new([&[0xFF, 0xFE][..], &utf16].concat()) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["wide".to_string()]); + let shift_jis = b"
".to_vec(); + assert!(s.apply(id, Edit::ImportData { name: "sj.xml".into(), bytes: Arc::new(shift_jis) }).is_err()); + // A data file for another form is refused. + assert!(s.apply(id, Edit::ImportData { name: "o.xml".into(), bytes: Arc::new(b"1".to_vec()) }).is_err()); + assert_eq!(field(&s, "other").value, vec!["wide".to_string()]); + // XFDF still goes the usual way on an XFA form. + let xfdf = + "9999"; + s.apply(id, Edit::ImportData { name: "d.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(field(&s, "pin").value, vec!["9999".to_string()]); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + // A picture chosen for the image field goes into the data and onto the page. + let png = pdfcraft_xfa::fixtures::tiny_png(); + assert_eq!(field(&s, "photo").button, Some(pdfcraft_forms::af::ButtonAction::ImportIcon), "clicking it picks a picture"); + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert!(d.text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("R0lGODlh")); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\"")); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo"); + // Only pictures the form can show; only image fields. + assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err()); + assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err()); + // One datasets stream per edit, however many steps it took. + let before = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap().object_numbers().len(); + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "pin".into(), value: FieldValue::Text("1".into()) }).unwrap(); + let after = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let datasets = after + .object_numbers() + .into_iter() + .filter(|n| matches!(&*after.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|b| b.starts_with(b"Ada1")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("static.pdf", None, original, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name.ends_with(n)).map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(value(&s, "name[0]"), vec!["Ada".to_string()]); + assert_eq!(value(&s, "agree[0]"), vec!["1".to_string()]); + // The file names only `name`: `agree` is emptied too, since the data was replaced. + s.apply(id, Edit::ImportData { name: "d.xml".into(), bytes: Arc::new(b"Bob".to_vec()) }).unwrap(); + assert_eq!(value(&s, "name[0]"), vec!["Bob".to_string()]); + assert!(value(&s, "agree[0]").is_empty(), "{:?}", value(&s, "agree[0]")); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Bob")); + assert!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")).is_none_or(str::is_empty)); + let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap(); + assert!(xml.contains("Bob") && !xml.contains("1"), "{xml}"); +} + +#[test] +fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { + 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("rows.pdf", None, bytes, None).expect("opens"); + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1); + let file = "3a10b32c0.5
"; + s.apply(id, Edit::ImportData { name: "rows.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s)); + assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id)); + assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]); + // More rows than the layout shows: the document says so. + let rows: String = (0..60).map(|i| format!("r{i}1")).collect(); + let file = format!("
1{rows}
"); + s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}"); + // A file with one row takes the others away. + let file = "
1only7
"; + s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1, "{:?}", names(&s)); + assert_eq!(value(&s, "grand"), vec!["7".to_string()]); + // Not an XFA data file on an XFA form: refused with the usual message, nothing changed. + assert!(s.apply(id, Edit::ImportData { name: "junk.xml".into(), bytes: Arc::new(b"".to_vec()) }).is_err()); + assert_eq!(value(&s, "grand"), vec!["7".to_string()]); + // The automation tool's view: export then import through a file round-trips the rows. + let out = s.export_data(id, DataFormat::Xdp, false, true).unwrap(); + assert!(String::from_utf8_lossy(&out).contains("only")); +} + #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index bca3bc36ea..b0ee84d35d 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -693,6 +693,17 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names r.finish().map(|_| ()) } +/// The form's data changed outside its scripts (a data file imported, a picture chosen): lay +/// the form out again from the data, then, when `calculate`, run every calculation. +pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, calculate: bool, run: &mut XfaRun) -> Result<(), String> { + let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); + r.relayout()?; + if calculate { + r.calculates()?; + } + r.finish().map(|_| ()) +} + /// A button's click script (or any event, by SOM path and activity), then calculations. pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> { let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index b3de8ca60b..337ae7652a 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -254,6 +254,11 @@ const MAX_LAYER_STATE: usize = 1024; /// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { + // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): + // clicking it picks a picture, as `buttonImportIcon()` does. + if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return Some(af::ButtonAction::ImportIcon); + } let pick = |dict: &Dict| -> Option { dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) }; diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 96149f2e48..87b2f833b4 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -695,11 +695,11 @@ impl PdfCraftApp { Err(e) => app.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), }; match self.save_override.clone() { - Some(p) if [".xfdf", ".fdf", ".xml", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()), + Some(p) if [".xfdf", ".fdf", ".xml", ".xdp", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()), Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "csv", "txt"]) + .add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "xdp", "csv", "txt"]) .set_title(tl!("Import data").to_string()); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, import); @@ -820,6 +820,7 @@ impl PdfCraftApp { let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); #[cfg(not(target_arch = "wasm32"))] { + let xfa = doc.info.xfa.is_some(); let write = move |app: &mut Self, path: std::path::PathBuf| { // Include what was typed while the save panel was open (#166). if !app.commit_typing_in(id) { @@ -842,6 +843,15 @@ impl PdfCraftApp { let d = rfd::AsyncFileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf")); let d = if comments { d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"]) + } else if xfa { + // An XFA form's data, as Acrobat exports it, first. + d.set_file_name(format!("{stem}.xml")) + .add_filter("XML", &["xml"]) + .add_filter("XDP", &["xdp"]) + .add_filter("XFDF", &["xfdf"]) + .add_filter("FDF", &["fdf"]) + .add_filter("CSV", &["csv"]) + .add_filter(tl!("Text").to_string(), &["txt"]) } else { d.add_filter("XFDF", &["xfdf"]) .add_filter("FDF", &["fdf"]) diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 3008e3dea5..be56fb4d45 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -83,8 +83,7 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for ## Deliberately not done (yet) - **Data binding** is the default one only: explicit `bind ref` expressions, global binding and - data descriptions are not followed, and no standalone XML or XDP data file is imported or - exported. + data descriptions are not followed (a data description is kept as written, not checked). - **Scripting.** JavaScript (boa) and FormCalc (a native interpreter, `pdfcraft_js::formcalc`) share one object model, one set of effects and one set of budgets. FormCalc covers the language and the common built-ins (arithmetic, logical, string, date/time, financial, unit); @@ -119,7 +118,16 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for values of `` as the options; the data holds saved values, several on separate lines. Password fields are password text fields. Signature fields are `/Sig` fields the app can sign. Image fields show the picture the data or template holds (JPEG, PNG, GIF) - but a new picture can't be chosen; barcode fields show their value as text, not as bars. + and are buttons that pick a new one, kept in the data as base64 with its `xfa:contentType` + (`write_data_image`); barcode fields show their value as text, not as bars. +- **Data files.** `export_data` writes the data as Acrobat's Export Data does: an XML document + holding the data alone (the form's root data node at the root, its namespaces declared) or + an XDP holding the whole datasets and a reference to the PDF. `import_data` takes an XDP, a + datasets or data element, or a data document whose root is the form's root data node, and + makes it the form's whole data, keeping the rest of the datasets (data descriptions). The + file's data is held as a tree and written with the packet's own namespace prefix, never as + the file's markup, and every rewrite of the packet is parsed back before it is written; the + engine then lays a dynamic form out again from it and runs its calculations. - Missing: `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, font metrics are the approximate Helvetica ones, and line heights are 1.15 × size. - Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 9c0f51dd1a..504e4b3678 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -454,6 +454,10 @@ enum Op<'a> { Values(&'a [Want]), /// Take the node at this path out. Remove(&'a DataPath), + /// Make these nodes the whole content of the `xfa:data` element (an import). + ReplaceData(&'a [ImportNode]), + /// Make the leaf at this path a picture: base64 text with its content type. + Picture(&'a DataPath, &'a str, &'a str), } /// Note that a value could not be written (once per kind of reason, naming a few fields). @@ -582,10 +586,133 @@ const WRAP_CLOSE: &str = ""; /// change: values go into the data nodes they bind to, missing nodes are added, and everything /// else (data no field binds to, other namespaces, attributes, comments, layout) stays as /// written. +/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default +/// namespace, which attributes can't use). +fn xfa_prefix(n: roxmltree::Node) -> Option { + n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) +} + +/// Data read from a file, held as a tree so it can be written into any packet with the +/// prefix that packet uses, never as the file's own markup. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImportNode { + pub name: String, + /// The element's namespace (`None`: none). + pub ns: Option, + /// `(namespace, name, value)`. + pub attrs: Vec<(Option, String, String)>, + pub text: String, + pub children: Vec, +} + +/// Deepest data an import keeps. +const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; + +/// `n` as an [`ImportNode`], counting nodes against `budget`; `None` past the depth or node +/// limits. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option { + if depth > MAX_IMPORT_DEPTH || *budget == 0 { + return None; + } + *budget -= 1; + let mut children = Vec::new(); + let mut text = String::new(); + for c in n.children() { + if c.is_element() { + children.push(import_node(c, depth + 1, budget)?); + } else if let Some(t) = c.text() { + text.push_str(t); + } + } + // A group's own text is layout whitespace. + if !children.is_empty() && text.trim().is_empty() { + text.clear(); + } + Some(ImportNode { + name: n.tag_name().name().to_string(), + ns: n.tag_name().namespace().map(str::to_string), + attrs: n + .attributes() + .filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) + .map(|a| (a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())) + .collect(), + text, + children, + }) +} + +/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where +/// they go); other namespaces are declared on the elements and attributes that use them. +fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { + let mut out = String::new(); + for n in nodes { + serialize_node(n, xfa, &mut out, 0); + } + out +} + +fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { + if depth > MAX_IMPORT_DEPTH { + return; + } + let mut decls: Vec<(String, String)> = Vec::new(); + let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { + match ns.as_deref() { + None => xml_name(name), + Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), + Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), + Some(uri) => { + let p = match decls.iter().position(|(_, u)| u == uri) { + Some(i) => format!("n{i}"), + None => { + decls.push((format!("n{}", decls.len()), uri.to_string())); + format!("n{}", decls.len() - 1) + } + }; + let _ = attr; + format!("{p}:{}", xml_name(name)) + } + } + }; + let tag = qualify(&n.ns, &n.name, false); + let attrs: Vec = n + .attrs + .iter() + .take(256) + .map(|(ns, name, value)| { + let q = qualify(ns, name, true); + format!(" {q}=\"{}\"", escape(value).0) + }) + .collect(); + out.push('<'); + out.push_str(&tag); + for (p, uri) in &decls { + out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); + } + for a in &attrs { + out.push_str(a); + } + if n.text.is_empty() && n.children.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + out.push_str(&escape(&n.text).0); + for c in &n.children { + serialize_node(c, xfa, out, depth + 1); + } + out.push_str(&format!("")); +} + fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { let wants: &[Want] = match op { Op::Values(w) => w, - Op::Remove(_) => &[], + Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], + }; + // Fresh data, where none exists yet (declared with the `xfa` prefix). + let fresh_children = |warnings: &mut Vec| match op { + Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), + _ => build_children(wants, warnings), }; if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); @@ -616,10 +743,11 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg let mut edits = Vec::new(); let mut data = None; match datasets { - None if matches!(op, Op::Remove(_)) => return Merged::Unchanged, + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, None if within == Within::Xdp => { let root = parsed.root_element(); - match append_edit(src, root, &fresh_datasets(&build_children(wants, warnings))) { + let children = fresh_children(warnings); + match append_edit(src, root, &fresh_datasets(&children)) { Some(e) => edits.push(e), None => { warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); @@ -633,10 +761,11 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg } Some(ds) => match ds.children().find(|n| is(n, "data")) { Some(d) => data = Some(d), - None if matches!(op, Op::Remove(_)) => return Merged::Unchanged, + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, None => { let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); - let element = format!("<{prefix}data>{}", build_children(wants, warnings)); + let children = fresh_children(warnings); + let element = format!("<{prefix}data>{}", children); match append_edit(src, ds, &element) { Some(e) => edits.push(e), None => { @@ -650,6 +779,52 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg if let Some(data) = data { match op { Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), + Op::ReplaceData(nodes) => { + // Everything between the start and end tags; `` opens up. Written + // with the prefix the packet binds to the data namespace. + let prefix = xfa_prefix(data); + let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); + let range = data.range(); + match (start_tag_end(src, &range), qname(src, &range)) { + (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { + edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); + } + (Some(tag_end), _) => { + let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + Op::Picture(path, content_type, base64) => { + let mut node = Some(data); + for (name, idx) in path.iter() { + let want = xml_name(name); + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); + } + let Some(n) = node else { return Merged::Unchanged }; + if n.children().any(|c| c.is_element()) { + // A data group, not a leaf: a picture would wipe what's under it. + skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); + return Merged::Failed; + } + let name = qname(src, &n.range()).unwrap_or("value").to_string(); + let (ct, _) = escape(content_type); + let attr = match xfa_prefix(n) { + Some(p) => format!("{p}:contentType=\"{ct}\""), + None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), + }; + edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); + } Op::Remove(path) => { // The element at `path`, with the whitespace before it. let mut node = Some(data); @@ -933,6 +1108,219 @@ pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result, +) -> Result { + use base64::Engine; + if path.is_empty() { + return Err(XfaError::Malformed("a picture needs a data path".into())); + } + if bytes.len() > MAX_DATA_IMAGE { + return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); + } + let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; + rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) +} + +/// What an import of a data file did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DataImport { + /// Element nodes the new data holds. + pub nodes: usize, + pub warnings: Vec, + /// The datasets stream written. + pub stream: Option, +} + +/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export +/// Data's output, Designer's), a bare data element, or a data document whose root is the +/// form's root data node (named `expect_root`, when the caller knows it). Anything else is +/// refused: a form's data is never replaced by what is not its data. +pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { + if text.len() > MAX_IMPORT { + return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); + } + let text = text.trim_start_matches('\u{feff}'); + if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; + let root = parsed.root_element(); + let local = root.tag_name().name(); + let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); + let data = match local { + "xdp" => { + let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; + ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "datasets" if in_xfa(&root) => { + root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "data" if in_xfa(&root) => root, + _ => { + if root.tag_name().namespace().is_some() { + return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); + } + if let Some(want) = expect_root + && local != want + { + return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); + } + let mut budget = MAX_NODES as usize; + let node = import_node(root, 0, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + return Ok(vec![node]); + } + }; + let mut budget = MAX_NODES as usize; + let mut out = Vec::new(); + for c in data.children().filter(|c| c.is_element()) { + out.push(import_node(c, 1, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?); + } + Ok(out) +} + +/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping +/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already +/// wrote. +pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { + let nodes = parse_data_file(text, expect_root)?; + fn count(n: &[ImportNode]) -> usize { + n.iter().map(|c| 1 + count(&c.children)).sum() + } + let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; + if !r.written { + return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); + } + Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) +} + +/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. +fn datasets_packet(doc: &Document) -> Result, XfaError> { + let Some(root) = doc.root() else { return Ok(None) }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; + let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; + let text_of = |o: &Object| -> Result, XfaError> { + match &*doc.resolve(o) { + Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), + _ => Ok(None), + } + }; + match &*xfa { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at.and_then(|i| items.get(i + 1)) { + Some(o) => text_of(o), + None => Ok(None), + } + } + Object::Stream(_) => text_of(&xfa), + _ => Ok(None), + } +} + +/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document +/// holding the data alone (the form's root data node at the root, or the data element when +/// there are several); true gives an XDP holding the whole datasets and a reference to the +/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. +pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { + if crate::read_packets(doc)?.is_none() { + return Err(XfaError::NotXfa); + } + let packet = datasets_packet(doc)?; + let (datasets, data) = match &packet { + Some(text) => match datasets_markup(text) { + Ok(Some(m)) => m, + Ok(None) => (fresh_datasets(""), String::new()), + Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), + }, + None => (fresh_datasets(""), String::new()), + }; + let mut out = String::from("\n"); + if xdp { + let (name, _) = escape(pdf_name); + out.push_str("\n"); + out.push_str(&datasets); + out.push_str(&format!("\n\n\n")); + } else { + out.push_str(if data.is_empty() { &datasets } else { &data }); + out.push('\n'); + } + Ok(out) +} + +/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the +/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets +/// element; `Err`: the text can't be read. +fn datasets_markup(text: &str) -> Result, ()> { + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + return Err(()); + } + let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { + Ok(d) => (text, d), + Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), + }; + let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; + let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); + let data_alone = match data { + Some(d) => { + let kids: Vec = d.children().filter(|c| c.is_element()).collect(); + match kids.as_slice() { + [one] => element_markup(src, *one).ok_or(())?, + _ => element_markup(src, d).ok_or(())?, + } + } + None => String::new(), + }; + Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) +} + +/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks +/// added to it, so it stands alone. +fn element_markup(src: &str, n: roxmltree::Node) -> Option { + let range = n.range(); + let text = src.get(range.clone())?; + let tag_end = start_tag_end(src, &range)?; + let start_tag = src.get(range.start..tag_end)?; + let mut decls = String::new(); + for ns in n.namespaces() { + let attr = match ns.name() { + Some(p) => format!("xmlns:{p}="), + None => "xmlns=".to_string(), + }; + if !start_tag.contains(&attr) { + decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); + } + } + if decls.is_empty() { + return Some(text.to_string()); + } + let name_end = qname(src, &range)?.len() + 1; + Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) +} + /// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script /// event changed becomes a single new datasets stream. When the packet is already the stream /// `reuse` (written earlier for the same event), that stream is replaced in place instead of @@ -1011,6 +1399,25 @@ fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) current } +/// Longest packet text written (the packets together are read up to 64 MB). +const MAX_PACKET_WRITE: usize = 48 << 20; + +/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that +/// isn't is not written: better the old data than an unreadable packet. +fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { + if text.len() > MAX_PACKET_WRITE { + warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); + return false; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() + || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); + if !ok { + warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); + } + ok +} + fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { let mut report = DatasetsWrite::default(); if ops.is_empty() { @@ -1034,6 +1441,9 @@ fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result) -> Result) -> Result { let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; + if !readable(&new, Within::Xdp, warnings) { + return Ok(report); + } let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); report.stream = Some(r); acro.set(b"XFA".to_vec(), Object::Ref(r)); @@ -1091,7 +1507,8 @@ fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> V let mut out = Vec::new(); for f in fields { let path = path_of(tpl, &som_of(doc, f)); - let Some(text) = data.text_at(&path) else { continue }; + let Some(text) = data.value_at(&path) else { continue }; + let text = text.as_str(); let value = match &f.data { FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { Some(pat) => iso_to_pattern(text, &pat), diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index ee8af4cf11..4d6f0911e0 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -60,6 +60,8 @@ pub enum WidgetKind { }, /// A signature field, to be signed in the app. Signature, + /// An image field: a push button that picks a picture (the picture itself is page content). + Image, /// A date field with its Acrobat format pattern (`yyyy-mm-dd`). Date(String), CheckBox { @@ -726,6 +728,7 @@ impl Layouter<'_> { } // Data adds instances of a repeating subform (rows added in another viewer). let instances = self.instances(node, &ctx.som); + self.warn_truncated(node, &ctx.som, instances); let ctx_parent = ctx; for i in 0..instances { let ctx = ctx_parent.with_som(&sf.common, sib + i); @@ -791,6 +794,7 @@ impl Layouter<'_> { { // Rows added as data (in another viewer) repeat the row. let instances = self.instances(child, &ctx.som); + self.warn_truncated(child, &ctx.som, instances); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); if self.make_room(h)? @@ -837,6 +841,22 @@ impl Layouter<'_> { instance_count(&sf.occur, from_data) } + /// The data holds more instances of `node` than `laid_out`: say so, since calculations + /// then see only those. + fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { + let Node::Subform(sf) = node else { return }; + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + if from_data > laid_out { + self.warn_once(format!( + "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", + sf.common.name.as_deref().unwrap_or("a subform") + )); + } + } + fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { let c = node.common(); let spent = self.measures.get().saturating_add(1); @@ -1499,8 +1519,7 @@ impl Layouter<'_> { (None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?, _ => {} } - self.warn_once("image fields show their picture; a new picture can't be chosen yet"); - return Ok(()); + // Then the field itself, which clicking gives a new picture. } Ui::Barcode => { // The value as text, where the bars would be. @@ -1616,6 +1635,10 @@ impl Layouter<'_> { (kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone()) } Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()), + Ui::ImageEdit => { + let none = WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + (WidgetKind::Image, ui, none, None, f.tooltip.clone()) + } Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), _ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), }; diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index f9fd29d3b0..8dc5d20e4d 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -89,6 +89,7 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize { max } +pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use data::{ DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index e3413112f6..54e70a35b0 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -156,6 +156,10 @@ pub struct Written { pub warnings: Vec, } +/// On a push button generated for an XFA image field: clicking it picks a picture (the forms +/// layer reads this key). +pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage"; + /// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A /// re-layout (after every script event) finds the XObjects on the pages it replaces by this, /// and reuses them instead of decoding and embedding the same picture again. Nothing is kept @@ -370,6 +374,13 @@ impl Emitter<'_> { ff |= 1; } } + WidgetKind::Image => { + // A push button with nothing drawn: the forms layer treats one so marked as an + // image field (clicking picks a picture), with no action other viewers run. + d.set(b"FT".to_vec(), Object::name("Btn")); + ff |= 1 << 16; + d.set(IMAGE_FIELD_KEY.to_vec(), Object::Bool(true)); + } WidgetKind::CheckBox { on, .. } => { d.set(b"FT".to_vec(), Object::name("Btn")); let on = state_name(on); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 5f8919915a..4681ba01ea 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -830,7 +830,8 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert!(matches!(&other.kind, WidgetKind::Choice { list_box: false, multi: false, editable: true, .. })); assert_eq!(w.iter().find(|w| w.name == "pin").unwrap().kind, WidgetKind::Password); assert_eq!(w.iter().find(|w| w.name == "sign").unwrap().kind, WidgetKind::Signature); - assert!(w.iter().all(|w| w.name != "photo" && w.name != "code"), "image and barcode fields make no widget"); + assert_eq!(w.iter().find(|w| w.name == "photo").unwrap().kind, WidgetKind::Image, "an image field is a button that picks a picture"); + assert!(w.iter().all(|w| w.name != "code"), "barcode fields make no widget"); // The PNG in the image field and the GIF in the draw are pictures, fitted to their boxes. let images: Vec<(&Rect, usize)> = p1.items.iter().filter_map(|i| if let Item::Image { rect, data, .. } = i { Some((rect, data.len())) } else { None }).collect(); @@ -839,13 +840,14 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert_eq!(images[1].1, TINY_GIF.len()); let t = texts(p1); assert!(t.iter().any(|s| s == "12345"), "the barcode's value as text: {t:?}"); - assert!(form.warnings.iter().any(|w| w.contains("barcode")) && form.warnings.iter().any(|w| w.contains("image fields")), "{:?}", form.warnings); + assert!(form.warnings.iter().any(|w| w.contains("barcode")), "{:?}", form.warnings); + assert!(!form.warnings.iter().any(|w| w.contains("can't be chosen")), "pictures can be chosen now: {:?}", form.warnings); // Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password // field, and Flate images (the PNG with a soft mask). let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); let report = render_into(&mut doc).unwrap(); - assert_eq!(report.fields, 6, "country, langs, other, pin, sign, summary"); + assert_eq!(report.fields, 7, "country, langs, other, pin, sign, photo, summary"); let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); let doc = Document::open(Arc::new(bytes)).unwrap(); let fields = pdfcraft_forms_free_fields(&doc); @@ -956,7 +958,7 @@ fn pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocate .replace("", "AAAA"); let mut doc = Document::open(Arc::new(shell(&xml))).unwrap(); let report = render_into(&mut doc).unwrap(); - assert_eq!(report.fields, 6); + assert_eq!(report.fields, 7); assert!(report.warnings.iter().any(|w| w.contains("image")), "{:?}", report.warnings); } @@ -1570,3 +1572,219 @@ fn a_carriage_return_and_line_feed_are_one_line_break() { let lf = form.pages.iter().flat_map(widgets).find(|w| w.name == "notes").unwrap().rect.h; assert!((crlf - lf).abs() < 0.01); } + +#[test] +fn data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them() { + use crate::data::{export_data, import_data}; + let data = "
JPEnglish\nSpanish
"; + let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap(); + render_into(&mut doc).unwrap(); + // XML: the data alone, rooted at the form's root data node, as Acrobat's XML export is. + let xml = export_data(&doc, false, "fields.pdf").unwrap(); + assert!(xml.starts_with("\nJP") && xml.contains("English\nSpanish"), "{xml}"); + assert!(xml.trim_end().ends_with(""), "{xml}"); + assert_eq!(crate::parse_data_file(&xml, Some("form")).unwrap().len(), 1, "the export is importable"); + // XDP: the datasets inside an xdp envelope with a reference to the PDF. + let xdp = export_data(&doc, true, "my \"form\".pdf").unwrap(); + assert!(xdp.contains("") && xdp.contains(""), "{xdp}"); + assert!(xdp.contains(""), "{xdp}"); + assert_eq!(parse_datasets(&xdp).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP")); + // A form without a datasets packet exports empty data. + let bare = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + let xml = export_data(&bare, false, "x.pdf").unwrap(); + assert!(xml.contains("") || xml.contains(""), "{xml}"); + // A datasets packet alone (no template) still exports its data; no XFA at all is an error. + let static_doc = Document::open(Arc::new(static_shell("Ada"))).unwrap(); + assert!(export_data(&static_doc, false, "s.pdf").unwrap().contains("Ada")); + assert!(export_data(&static_doc, true, "s.pdf").unwrap().contains(""))).unwrap(); + assert!(export_data(&plain, false, "p.pdf").unwrap().contains("", + "
", + ); + let shapes = [ + format!( + "\nCA" + ), + format!("
CA
"), + format!("\u{feff}
CA
"), + "
CA
".to_string(), + ]; + for file in &shapes { + let mut doc = Document::open(Arc::new(shell(&with_desc))).unwrap(); + render_into(&mut doc).unwrap(); + let r = import_data(&mut doc, file, Some("form"), None).unwrap(); + assert_eq!(r.nodes, 3, "{file}"); + let d = crate::data_of(&doc).unwrap(); + assert_eq!(d.text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "{file}"); + assert_eq!(d.text_at(&som_to_path("form[0].page1[0].langs[0]")), None, "the old data is gone: {file}"); + let xdp = export_data(&doc, true, "x.pdf").unwrap(); + assert!(xdp.contains("dataDescription"), "kept: {xdp}"); + assert!(!export_data(&doc, false, "x.pdf").unwrap().contains("dataDescription"), "the XML export is the data alone"); + // Laid out again, the form shows the imported value. + let (t, _) = parse(&with_desc).unwrap(); + rerender(&mut doc, &t).unwrap(); + let fields = pdfcraft_forms_free_fields(&doc); + let c = &fields.iter().find(|(n, _)| n == "country").unwrap().1; + assert_eq!(c.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("CA"), "{file}"); + } + // Not XFA data: refused, the document untouched. + let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap(); + render_into(&mut doc).unwrap(); + for bad in [ + "", + "1", + "not xml at all", + "", + ] { + assert!(import_data(&mut doc, bad, Some("form"), None).is_err(), "{bad}"); + } + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP")); + // Empty data: the data element is emptied, not removed. + let r = import_data(&mut doc, &format!(""), Some("form"), None).unwrap(); + assert_eq!(r.nodes, 0); + assert!(crate::data_of(&doc).is_some()); + assert_eq!(crate::data_of(&doc).unwrap().children.len(), 0); + // Hostile: a file over the size cap, and one with more nodes than the parser allows. + assert!(import_data(&mut doc, &"x".repeat(17 << 20), None, None).is_err()); + let many = format!("
{}
", "".repeat(1_100_000)); + assert!(import_data(&mut doc, &many, None, None).is_err()); + // A self-closing data element opens up for an import. + let empty = fields_template("") + .replace("", ""); + let mut doc = Document::open(Arc::new(shell(&empty))).unwrap(); + render_into(&mut doc).unwrap(); + import_data(&mut doc, "
FR
", Some("form"), None).unwrap(); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("FR")); +} + +const XFA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; + +#[test] +fn a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type() { + use crate::data::write_data_image; + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let path = som_to_path("form[0].page1[0].photo[0]"); + // No data node yet: it is made. + let r = write_data_image(&mut doc, &path, "image/gif", TINY_GIF, None).unwrap(); + assert!(r.written, "{:?}", r.warnings); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("R0lGODlh"), "{xdp}"); + // Written again: replaced, not duplicated; a hostile content type is cleaned. + let r = write_data_image(&mut doc, &path, "image/png\">iVBORw0KGgo"), "{xdp}"); + assert!(!xdp.contains("")); + // The form shows it: laid out again, the page's picture is the PNG from the data. + let (t, _) = parse(&fields_template("")).unwrap(); + rerender(&mut doc, &t).unwrap(); + let d = crate::data_of(&doc).unwrap(); + assert!(d.text_at(&path).unwrap().starts_with("iVBORw0KGgo")); + // Too big is refused before anything is written. + assert!(write_data_image(&mut doc, &path, "image/png", &vec![0u8; (8 << 20) + 1], None).is_err()); + assert!(write_data_image(&mut doc, &Vec::new(), "image/png", TINY_GIF, None).is_err()); + // The image field is a push button marked as one, with no action and nothing drawn. + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let fields = pdfcraft_forms_free_fields(&doc); + let photo = &fields.iter().find(|(n, _)| n == "photo").unwrap().1; + assert_eq!(photo.name(b"FT"), Some(b"Btn".as_slice())); + assert_eq!(photo.int(b"Ff"), Some(1 << 16)); + assert_eq!(photo.get(b"PCXfaImage"), Some(&Object::Bool(true))); + assert!(!photo.contains(b"A") && !photo.contains(b"MK")); +} + +#[test] +fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unreadable() { + use crate::data::{export_data, import_data, write_data_image}; + // A file binding the data namespace to another prefix, with an attribute in it. + let file = format!( + "
CA
" + ); + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); + render_into(&mut doc).unwrap(); + import_data(&mut doc, &file, Some("form"), None).unwrap(); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("
"), "{xdp}"); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA")); + // An xsi:nil declared on an envelope the file's data element doesn't carry. + let file = "1
"; + import_data(&mut doc, file, Some("form"), None).unwrap(); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("xmlns:n0=\"http://www.w3.org/2001/XMLSchema-instance\" n0:nil=\"true\""), "{xdp}"); + assert!(crate::data_of(&doc).is_some(), "the packet still parses"); + // Our own export, from a packet using another prefix, imports back. + let other = fields_template("
7
") + .replace("xfa:datasets", "xd:datasets") + .replace("xfa:data>", "xd:data>") + .replace("xmlns:xfa=", "xmlns:xd="); + let mut doc2 = Document::open(Arc::new(shell(&other))).unwrap(); + render_into(&mut doc2).unwrap(); + let exported = export_data(&doc2, true, "o.pdf").unwrap(); + import_data(&mut doc, &exported, Some("form"), None).unwrap(); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].pin[0]")), Some("7")); + // A picture into that other-prefix packet uses its prefix. + let path = som_to_path("form[0].page1[0].photo[0]"); + write_data_image(&mut doc2, &path, "image/gif", TINY_GIF, None).unwrap(); + let xdp = crate::read_packets(&doc2).unwrap().unwrap().xdp; + assert!(xdp.contains(""), "{xdp}"); + assert!(crate::data_of(&doc2).is_some()); + // A picture where the data node is a group is refused, the group kept. + let grouped = fields_template("
keep me
"); + let mut doc3 = Document::open(Arc::new(shell(&grouped))).unwrap(); + render_into(&mut doc3).unwrap(); + let r = write_data_image(&mut doc3, &path, "image/gif", TINY_GIF, None).unwrap(); + assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured")), "{r:?}"); + assert!(crate::read_packets(&doc3).unwrap().unwrap().xdp.contains("keep me")); + // A field name XML can't hold is written under the same cleaned name the values use. + let odd = fields_template("").replace("name=\"photo\"", "name=\"Foto_Straße\""); + let mut doc4 = Document::open(Arc::new(shell(&odd))).unwrap(); + render_into(&mut doc4).unwrap(); + let r = write_data_image(&mut doc4, &som_to_path("form[0].page1[0].Foto_Straße[0]"), "image/gif", TINY_GIF, None).unwrap(); + assert!(r.written, "{r:?}"); + let xdp = crate::read_packets(&doc4).unwrap().unwrap().xdp; + assert_eq!(xdp.matches("R0lG"), "{xdp}"); + // What is not this form's data is refused: an SVG, an XDP without datasets, an XDP whose + // config has a element before the datasets, a data document with another root. + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); + render_into(&mut doc).unwrap(); + for bad in [ + "".to_string(), + "", + &format!( + "
{rows}
" + ), + ); + let form = layout_xml(&many).unwrap(); + assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings); +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 4998e75437..beec3937e1 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -41,6 +41,9 @@ pub enum Format { Xml, Csv, Txt, + /// An XFA form's data as an XML Data Package (the engine writes it; for a form without + /// XFA packets it is the XML format). + Xdp, } impl Format { @@ -49,6 +52,7 @@ impl Format { "xfdf" => Format::Xfdf, "fdf" => Format::Fdf, "xml" => Format::Xml, + "xdp" => Format::Xdp, "csv" => Format::Csv, "txt" | "tab" | "tsv" => Format::Txt, _ => return None, @@ -577,7 +581,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { }) .collect(); match format { - Format::Xml => { + Format::Xml | Format::Xdp => { let mut out = String::from("\n\n"); for (k, v) in &fields { let tag = xml_name(k); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 50b16cd5ee..2667459a2f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4639,7 +4639,7 @@ status = "shipped" commands = ["form.import_data"] tools = ["doc_import_data"] evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF; values go through formats and validation; read-only fields take data" +notes = "XFDF, FDF; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)" [[feature]] id = "form.export-data" @@ -4651,7 +4651,7 @@ status = "shipped" commands = ["form.export_data"] tools = ["doc_export_data"] evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF" +notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)" [[feature]] id = "form.data-xml-csv-txt" @@ -5200,10 +5200,10 @@ area = "F" tier = "P2" milestone = "M12" status = "partial" -commands = ["file.open", "file.save"] -tools = ["doc_open", "form_fill", "doc_save"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions" +commands = ["file.open", "file.save", "form.export_data", "form.import_data"] +tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten"