M6: Merge data files into spreadsheet (FDF, XFDF, filled PDF forms → CSV); form_merge_data tool

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 04:52:59 -04:00
1 parent 9321864393
commit 6ff0bf8f64
17 files changed
+266 -45

No files matched your search

+2 -1
View File
@@ -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 |
+11
View File
@@ -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\""
+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-file-spreadsheet"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M8 13h2" />
<path d="M14 13h2" />
<path d="M8 17h2" />
<path d="M14 17h2" />
</svg>

After

Width:  |  Height:  |  Size: 554 B

+13
View File
@@ -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<Value> {
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<Value> {
if let Some(on) = a.opt_bool("enabled")? {
self.session.set_javascript(on);
+1
View File
@@ -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)?,
+3
View File
@@ -737,6 +737,9 @@ pub fn tools() -> Vec<ToolDef> {
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(
+16
View File
@@ -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");
}
+1
View File
@@ -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"),
+8
View File
@@ -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<u8>)]) -> Result<String, String> {
let rows =
files.iter().map(|(name, bytes)| printcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::<Result<Vec<_>, _>>()?;
Ok(printcraft_xfdf::merge_csv(&rows))
}
pub type SplitPart = (usize, usize, Arc<Vec<u8>>);
use std::sync::Arc;
+1
View File
@@ -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;
+48
View File
@@ -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<u8>)>) {
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 };
+1
View File
@@ -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")),
+1
View File
@@ -55,6 +55,7 @@ const PICKERS: &[&str] = &[
"create.clipboard",
"a11y.report",
"ocr.recognize_batch",
"form.merge_data",
];
#[test]
+14
View File
@@ -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:"));
}
+103 -39
View File
@@ -663,18 +663,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, DataError> {
// 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<String>)>) {
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<String> = 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<Report, DataError> {
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<String>)>) {
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<String> = 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<String>)>, 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<String> = 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<Dict, DataError> {
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<String>)>, 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<Vec<(String, Vec<String>)>, 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<String> = 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>)>]) -> 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::<Vec<_>>().join(",");
out.push('\n');
for f in files {
let row: Vec<String> = 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<Report, DataError> {
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<ObjRef> = printcraft_model::pages(doc).iter().map(|p| p.obj).collect();
+18
View File
@@ -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));
}
+5 -5
View File
@@ -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"