From ec5d9da36c0db0c1b0c87caee24e3413dca561ab Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:24:27 +0200 Subject: [PATCH] fix(xfdf): text data from a one-field form keeps its commas (#1321) (#1371) The tab-delimited export of a single field has no tab, so import read it as CSV and kept only the text before the first comma. CSV quotes a value that holds a comma, so a single name over several unquoted cells is now read as that tab-delimited text. Exported files are unchanged, and CSV (quoted, multi-line, one or more columns) reads as before. --- crates/xfdf/src/lib.rs | 15 ++++++++---- crates/xfdf/src/tests.rs | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 93bf0b70a..ebc4b881c 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1093,9 +1093,7 @@ fn import_table(doc: &mut Document, text: &str) -> Result { }) .collect() } else if t.lines().next().is_some_and(|l| l.contains('\t')) { - let mut lines = t.lines(); - let (Some(head), Some(row)) = (lines.next(), lines.next()) else { return Err(DataError::UnknownFormat) }; - head.split('\t').map(str::to_string).zip(row.split('\t')).map(|(k, v)| (k, vec![v.to_string()])).collect() + tab_delimited(t)? } else { // CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks. let mut records: Vec> = Vec::new(); @@ -1124,12 +1122,21 @@ fn import_table(doc: &mut Document, text: &str) -> Result { // Blank lines (such as the one a trailing line break leaves) are not records. let mut records = records.into_iter().filter(|r| !matches!(r.as_slice(), [only] if only.is_empty())); let (Some(head), Some(row)) = (records.next(), records.next()) else { return Err(DataError::UnknownFormat) }; - head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect() + // Text exported from a one-field form has no tab to show for it (#1321). CSV quotes a + // value with a comma, so one name over several unquoted cells is that text, not CSV. + if head.len() == 1 && row.len() > 1 { tab_delimited(t)? } else { head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect() } }; apply_values(doc, &values, &mut report); Ok(report) } +/// Tab-delimited text: a row of names, a row of values. +fn tab_delimited(t: &str) -> Result)>, DataError> { + let mut lines = t.lines(); + let (Some(head), Some(row)) = (lines.next(), lines.next()) else { return Err(DataError::UnknownFormat) }; + Ok(head.split('\t').map(str::to_string).zip(row.split('\t')).map(|(k, v)| (k, vec![v.to_string()])).collect()) +} + /// Import comments and/or field values from XFDF, FDF, XML, CSV or tab-delimited text /// (detected from the content). pub fn import(doc: &mut Document, bytes: &[u8]) -> Result { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index fdf4056eb..524ed2866 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -328,6 +328,57 @@ fn csv_values_may_span_lines() { assert!(import(&mut blank(), b"Name,Agree\r\n\"Ada, Yes\r\n").is_ok()); } +/// One page with one text field, `Name`. +fn one_field() -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let page = doc.add(p); + doc.update_dict(pages, |d| { + d.set(b"Count".to_vec(), Object::Int(1)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("Name")).unwrap(); + Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() +} + +/// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the +/// value used to split it and only the text before the comma was imported. +#[test] +fn one_field_text_data_keeps_its_commas() { + for value in ["Hello, world", "Say \"hi\", twice"] { + let mut src = one_field(); + set_value(&mut src, "Name", &FieldValue::Text(value.into())).unwrap(); + let txt = export_data(&src, Format::Txt); + assert_eq!(txt, format!("Name\n{value}\n")); + let mut dst = one_field(); + let r = import(&mut dst, txt.as_bytes()).unwrap(); + assert_eq!(r.fields, 1, "{r:?}"); + assert!(r.rejected.is_empty(), "{r:?}"); + assert_eq!(values(&dst)[0].1, [value]); + } +} + +/// A one-column CSV is still CSV: quotes come off, and a quoted value keeps its commas and line +/// breaks. +#[test] +fn one_column_csv_still_reads_its_quotes() { + let mut src = one_field(); + set_value(&mut src, "Name", &FieldValue::Text("Hello, world".into())).unwrap(); + let csv = export_data(&src, Format::Csv); + assert_eq!(csv, "Name\n\"Hello, world\"\n"); + let mut dst = one_field(); + assert_eq!(import(&mut dst, csv.as_bytes()).unwrap().fields, 1); + assert_eq!(values(&dst)[0].1, ["Hello, world"]); + let mut dst = one_field(); + assert_eq!(import(&mut dst, b"Name\r\n\"Ada \"\"the\"\", \r\nCountess\"\r\n").unwrap().fields, 1); + assert!(values(&dst)[0].1[0].starts_with("Ada \"the\", ") && values(&dst)[0].1[0].ends_with("Countess"), "{:?}", values(&dst)); +} + #[test] fn multi_select_lists_round_trip_through_xml_csv_and_text() { let src = filled();