From 6ff0bf8f64d216152a2d566d608d2e5ec413afa4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:52:59 -0400 Subject: [PATCH] =?UTF-8?q?M6:=20Merge=20data=20files=20into=20spreadsheet?= =?UTF-8?q?=20(FDF,=20XFDF,=20filled=20PDF=20forms=20=E2=86=92=20CSV);=20f?= =?UTF-8?q?orm=5Fmerge=5Fdata=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 11 ++ assets/icons/file-spreadsheet.svg | 20 ++++ crates/automation/src/a11y.rs | 13 +++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 3 + crates/automation/tests/automation.rs | 16 +++ crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 8 ++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/files.rs | 48 +++++++++ crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/tests/commands.rs | 1 + crates/ui-egui/tests/javascript.rs | 14 +++ crates/xfdf/src/lib.rs | 142 +++++++++++++++++++------- crates/xfdf/src/tests.rs | 18 ++++ parity/acrobat-features.toml | 10 +- 17 files changed, 266 insertions(+), 45 deletions(-) create mode 100644 assets/icons/file-spreadsheet.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index bbb07e683..cb5d1bf11 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (241) +## In this repository (242) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -13,6 +13,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 1055960cd..c2d437c70 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -63,6 +63,17 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "a52ebec6ffe56b062b3a7bc11a844be424555694736da11a4654481fb5668493" +[[asset]] +path = "assets/icons/file-spreadsheet.svg" +title = "Lucide icon \"file-spreadsheet\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "147afd6de42696232301090fcf32a506689e3b7f2b9e9ae588ed3c37f63d7e3a" + [[asset]] path = "assets/icons/align-justify.svg" title = "Lucide icon \"align-justify\"" diff --git a/assets/icons/file-spreadsheet.svg b/assets/icons/file-spreadsheet.svg new file mode 100644 index 000000000..488313ccb --- /dev/null +++ b/assets/icons/file-spreadsheet.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 6a7f1009d..fb50563f9 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -246,6 +246,19 @@ impl Automation { Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) } + pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { + let mut files = Vec::new(); + for p in a.strs("paths")? { + let path = self.resolve(p, false)?; + files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?)); + } + let csv = printcraft_engine::merge_data_files(&files).map_err(failed)?; + let target = self.resolve(a.str("path")?, true)?; + write_atomic(&target, csv.as_bytes())?; + let columns = csv.lines().next().map_or(0, |h| h.split(',').count()); + Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) + } + pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { if let Some(on) = a.opt_bool("enabled")? { self.session.set_javascript(on); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 00572fec0..fe2e873d8 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -508,6 +508,7 @@ impl Automation { "js_set_document_script" => self.js_set_document_script(&a)?, "js_enabled" => self.js_enabled(&a)?, "form_set_script" => self.form_set_script(&a)?, + "form_merge_data" => self.form_merge_data(&a)?, "ocr_status" => self.ocr_status()?, "ocr_recognize_files" => self.ocr_recognize_files(&a)?, "accessibility_report" => self.a11y_report(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c19e943ce..77f99be94 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -737,6 +737,9 @@ pub fn tools() -> Vec { t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.") .cmd("a11y.alt_text") .with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])), + t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") + .cmd("form.merge_data") + .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") .cmd("tools.js_console") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index bc26ae620..84e9986ba 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1376,3 +1376,19 @@ fn javascript_through_tools() { assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false); assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err()); } + +#[test] +fn merging_form_data_into_a_spreadsheet() { + let dir = workdir("merge-data"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "City" })); + for (city, file) in [("Paris", "one.xfdf"), ("Oslo", "two.fdf")] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "City": city } })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": file, "what": "fields" })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" })); + let r = ok(&mut a, "form_merge_data", json!({ "paths": ["one.xfdf", "two.fdf", "filled.pdf"], "path": "report.csv" })); + assert_eq!(r["rows"], 3); + assert_eq!(std::fs::read_to_string(dir.join("report.csv")).unwrap(), "City\nParis\nOslo\nOslo\n"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7bb61eda4..a26ba4b6f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -201,6 +201,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"), c("form.import_data", "Import form data…", None, None, HasFields, "file-input"), c("form.export_data", "Export form data…", None, None, HasFields, "file-output"), + c("form.merge_data", "Merge data files into spreadsheet…", None, None, Nothing, "file-spreadsheet"), c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"), c("form.prepare", "Prepare a form", None, None, Modification, "text-cursor-input"), c("form.tab_order.row", "Tab order: by rows", None, None, HasFields, "rows-3"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a22a189d3..0352b5759 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -82,6 +82,14 @@ pub use printcraft_sign as sign; pub use printcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; pub use printcraft_xfdf::Format as DataFormat; +/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a +/// filled-in PDF form), as CSV with one row per file. +pub fn merge_data_files(files: &[(String, Vec)]) -> Result { + let rows = + files.iter().map(|(name, bytes)| printcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; + Ok(printcraft_xfdf::merge_csv(&rows)) +} + pub type SplitPart = (usize, usize, Arc>); use std::sync::Arc; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index b53379f58..998515124 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -360,6 +360,7 @@ impl PrintCraftApp { } } "form.export_data" => self.export_data_dialog(false, true), + "form.merge_data" => self.merge_data_dialog(), "form.prepare" => { self.left = crate::LeftPanel::Tool("form"); self.left_open = true; diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 57af3ea3d..9d3162e25 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -391,6 +391,54 @@ impl PrintCraftApp { } /// Export all comments / form data: the format follows the file name's extension. + /// Forms ▸ Merge data files into spreadsheet: choose data files (FDF, XFDF or filled-in PDF + /// forms), then where to save the CSV. + pub fn merge_data_dialog(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let paths = rfd::FileDialog::new() + .set_title("Select data files to merge") + .add_filter("Form data and PDF forms", &["fdf", "xfdf", "pdf"]) + .pick_files() + .unwrap_or_default(); + let mut files = Vec::new(); + for p in paths { + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + match std::fs::read(&p) { + Ok(b) => files.push((name, b)), + Err(e) => return self.notify(format!("Couldn't read {name}: {e}")), + } + } + if !files.is_empty() { + self.merge_data_files(files); + } + } + #[cfg(target_arch = "wasm32")] + self.notify("Merging data files needs the desktop app"); + } + + /// Merge the given data files and save the spreadsheet (asks where; `save_override` in tests). + pub fn merge_data_files(&mut self, files: Vec<(String, Vec)>) { + let csv = match printcraft_engine::merge_data_files(&files) { + Ok(c) => c, + Err(e) => return self.notify(e), + }; + #[cfg(not(target_arch = "wasm32"))] + { + let path = match self.save_override.clone() { + Some(p) => Some(std::path::PathBuf::from(p)), + None => rfd::FileDialog::new().set_title("Save the spreadsheet").add_filter("CSV", &["csv"]).set_file_name("report.csv").save_file(), + }; + let Some(path) = path else { return }; + match crate::editing::write_atomically(&path.to_string_lossy(), csv.as_bytes()) { + Ok(()) => self.notify(format!("Merged {} file{} into {}", files.len(), if files.len() == 1 { "" } else { "s" }, path.display())), + Err(e) => self.notify(format!("Couldn't write {}: {e}", path.display())), + } + } + #[cfg(target_arch = "wasm32")] + let _ = crate::editing::download("report.csv", csv.as_bytes()); + } + pub fn export_data_dialog(&mut self, comments: bool, fields: bool) { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 97ee8aacf..fbb39d3c3 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -5,6 +5,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("accessibility", include_bytes!("../../../assets/icons/accessibility.svg")), ("align-center", include_bytes!("../../../assets/icons/align-center.svg")), ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), + ("file-spreadsheet", include_bytes!("../../../assets/icons/file-spreadsheet.svg")), ("file-code", include_bytes!("../../../assets/icons/file-code.svg")), ("square-terminal", include_bytes!("../../../assets/icons/square-terminal.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 4e1ea840d..298b6357a 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -55,6 +55,7 @@ const PICKERS: &[&str] = &[ "create.clipboard", "a11y.report", "ocr.recognize_batch", + "form.merge_data", ]; #[test] diff --git a/crates/ui-egui/tests/javascript.rs b/crates/ui-egui/tests/javascript.rs index fc2c8aeda..903d96d02 100644 --- a/crates/ui-egui/tests/javascript.rs +++ b/crates/ui-egui/tests/javascript.rs @@ -101,3 +101,17 @@ fn document_scripts_and_preferences() { fresh.restore(&saved); assert!(!fresh.session.javascript(), "the preference is remembered"); } + +#[test] +fn merge_data_files_into_a_spreadsheet() { + let dir = std::env::temp_dir().join(format!("printcraft-merge-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("report.csv"); + let mut app = PrintCraftApp::new(); + app.save_override = Some(out.to_string_lossy().into_owned()); + app.merge_data_files(vec![("form.pdf".into(), form())]); + assert_eq!(std::fs::read_to_string(&out).unwrap(), "greeting\n\n"); + assert!(app.toast.clone().unwrap().0.starts_with("Merged 1 file")); + app.merge_data_files(vec![("x.fdf".into(), b"junk".to_vec())]); + assert!(app.toast.clone().unwrap().0.starts_with("x.fdf:")); +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 81d6be7b2..aa8399acc 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -663,18 +663,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result { // Fields. if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) { let mut values = Vec::new(); - fn walk(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>) { - for f in node.children().filter(|c| c.has_tag_name("field")) { - let Some(name) = f.attribute("name") else { continue }; - let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }; - let vals: Vec = f.children().filter(|c| c.has_tag_name("value")).map(|v| v.text().unwrap_or("").to_string()).collect(); - if !vals.is_empty() { - out.push((full.clone(), vals)); - } - walk(f, &full, out); - } - } - walk(fields, "", &mut values); + walk_xfdf(fields, "", &mut values); apply_values(doc, &values, &mut report); } Ok(report) @@ -707,43 +696,118 @@ fn deep(src: &Document, o: &Object, depth: usize) -> Object { } } -fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { - let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; +fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>) { + for f in node.children().filter(|c| c.has_tag_name("field")) { + let Some(name) = f.attribute("name") else { continue }; + let full = if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }; + let vals: Vec = f.children().filter(|c| c.has_tag_name("value")).map(|v| v.text().unwrap_or("").to_string()).collect(); + if !vals.is_empty() { + out.push((full.clone(), vals)); + } + walk_xfdf(f, &full, out); + } +} + +fn walk_fdf(fdf: &Document, list: &[Object], prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) { + if depth > 32 { + return; + } + for f in list { + let Some(d) = fdf.resolve(f).as_dict().cloned() else { continue }; + let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; + let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { + let vals: Vec = match &*v { + Object::String(s) => vec![s.to_text()], + Object::Name(n) => vec![String::from_utf8_lossy(n).into_owned()], + Object::Array(a) => a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect(), + _ => Vec::new(), + }; + out.push((full.clone(), vals)); + } + if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)).and_then(|k| k.as_array().cloned()) { + walk_fdf(fdf, &kids, &full, out, depth + 1); + } + } +} + +fn fdf_body(fdf: &Document) -> Result { let root = fdf.root().ok_or_else(|| DataError::Malformed("no /Root".into()))?; - let body = fdf - .get(root) + fdf.get(root) .as_dict() .and_then(|d| d.get(b"FDF").cloned()) .map(|f| fdf.resolve(&f)) .and_then(|f| f.as_dict().cloned()) - .ok_or(DataError::UnknownFormat)?; - let mut report = Report::default(); - // Fields (with /Kids for hierarchical names). - let mut values = Vec::new(); - fn walk(fdf: &Document, list: &[Object], prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) { - if depth > 32 { - return; + .ok_or(DataError::UnknownFormat) +} + +/// The field values in a form data file (FDF or XFDF) or a filled-in PDF form, in file order. +pub fn data_values(bytes: &[u8]) -> Result)>, DataError> { + if bytes.starts_with(b"%FDF") { + let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; + let body = fdf_body(&fdf)?; + let mut values = Vec::new(); + if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { + walk_fdf(&fdf, &list, "", &mut values, 0); } - for f in list { - let Some(d) = fdf.resolve(f).as_dict().cloned() else { continue }; - let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; - let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; - if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { - let vals: Vec = match &*v { - Object::String(s) => vec![s.to_text()], - Object::Name(n) => vec![String::from_utf8_lossy(n).into_owned()], - Object::Array(a) => a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect(), - _ => Vec::new(), - }; - out.push((full.clone(), vals)); - } - if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)).and_then(|k| k.as_array().cloned()) { - walk(fdf, &kids, &full, out, depth + 1); + return Ok(values); + } + if bytes.starts_with(b"%PDF") || bytes.windows(5).take(1024).any(|w| w == b"%PDF-") { + let doc = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; + return Ok(printcraft_forms::fields(&doc) + .into_iter() + .filter(|f| !matches!(f.kind, FieldKind::PushButton | FieldKind::Signature)) + .map(|f| { + let v = + if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { vec!["Off".to_string()] } else { f.value }; + (f.name, v) + }) + .collect()); + } + let text = String::from_utf8_lossy(bytes); + let t = text.trim_start_matches('\u{feff}').trim_start(); + let xml = roxmltree::Document::parse(t).map_err(|_| DataError::UnknownFormat)?; + let root = xml.root_element(); + if root.tag_name().name() != "xfdf" { + return Err(DataError::UnknownFormat); + } + let mut values = Vec::new(); + if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) { + walk_xfdf(fields, "", &mut values); + } + Ok(values) +} + +/// Merge Data Files into Spreadsheet: one CSV row per file, one column per field name (in the +/// order they first appear). +pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { + let mut columns: Vec<&str> = Vec::new(); + for f in files { + for (k, _) in f { + if !columns.contains(&k.as_str()) { + columns.push(k); } } } + let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); + out.push('\n'); + for f in files { + let row: Vec = columns.iter().map(|c| f.iter().find(|(k, _)| k == c).map_or(String::new(), |(_, v)| q(&v.join(", ")))).collect(); + out.push_str(&row.join(",")); + out.push('\n'); + } + out +} + +fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { + let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; + let body = fdf_body(&fdf)?; + let mut report = Report::default(); + // Fields (with /Kids for hierarchical names). + let mut values = Vec::new(); if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk(&fdf, &list, "", &mut values, 0); + walk_fdf(&fdf, &list, "", &mut values, 0); } // Comments. let pages: Vec = printcraft_model::pages(doc).iter().map(|p| p.obj).collect(); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 5223459c5..b2bc5ffb3 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -160,3 +160,21 @@ fn bad_data_is_refused_with_a_reason() { assert_eq!(Format::from_extension("XFDF"), Some(Format::Xfdf)); assert_eq!(Format::from_extension("docx"), None); } + +#[test] +fn data_files_merge_into_a_spreadsheet() { + let src = filled(); + let xfdf = export_xfdf(&src, false, true, "form.pdf"); + let fdf = export_fdf(&src, false, true, "form.pdf"); + let mut other = blank(); + set_value(&mut other, "Name", &FieldValue::Text("Grace \"Amazing\" Hopper, RADM".into())).unwrap(); + let pdf = printcraft_cos::write_full(&other, &Default::default()).unwrap(); + let rows: Vec<_> = [xfdf.as_bytes(), &fdf[..], &pdf[..]].iter().map(|b| data_values(b).unwrap()).collect(); + assert_eq!(rows[0], rows[1], "XFDF and FDF carry the same values"); + let csv = merge_csv(&rows); + let lines: Vec<&str> = csv.lines().collect(); + assert_eq!(lines[0], "Name,Agree,Pick"); + assert_eq!(lines[1], "Ada Lovelace,Yes,\"A, C\""); + assert!(lines[3].starts_with("\"Grace \"\"Amazing\"\" Hopper, RADM\",Off,"), "{csv}"); + assert_eq!(data_values(b"not data"), Err(DataError::UnknownFormat)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b7bbec0b6..9aad132d8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4599,11 +4599,11 @@ title = "Merge form data files into a spreadsheet" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.merge_data"] +tools = ["form_merge_data"] +evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet"] +notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file." [[feature]] id = "form.flatten-fields"