mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
9321864393
commit
6ff0bf8f64
17 files changed
+266
-45
No files matched your search
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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\""
|
||||
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -55,6 +55,7 @@ const PICKERS: &[&str] = &[
|
||||
"create.clipboard",
|
||||
"a11y.report",
|
||||
"ocr.recognize_batch",
|
||||
"form.merge_data",
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user