From e9b9d98bb45b639c04c9469951e5eb0689f8f4ee Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:07:40 -0500 Subject: [PATCH] fix(xfdf): preserve quoted empty CSV values (#1541) --- crates/xfdf/src/lib.rs | 18 ++++++++++++------ crates/xfdf/src/tests.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index ebc4b881..d9287101 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1096,10 +1096,11 @@ fn import_table(doc: &mut Document, text: &str) -> Result { tab_delimited(t)? } else { // CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks. - let mut records: Vec> = Vec::new(); + let mut records: Vec<(Vec, bool)> = Vec::new(); let mut record = Vec::new(); let mut cur = String::new(); let mut quoted = false; + let mut has_quotes = false; let mut chars = t.chars().peekable(); while let Some(c) = chars.next() { match c { @@ -1107,20 +1108,25 @@ fn import_table(doc: &mut Document, text: &str) -> Result { cur.push('"'); chars.next(); } - '"' => quoted = !quoted, + '"' => { + quoted = !quoted; + has_quotes = true; + } ',' if !quoted => record.push(std::mem::take(&mut cur)), '\r' if !quoted && chars.peek() == Some(&'\n') => {} '\n' if !quoted => { record.push(std::mem::take(&mut cur)); - records.push(std::mem::take(&mut record)); + records.push((std::mem::take(&mut record), std::mem::take(&mut has_quotes))); } c => cur.push(c), } } record.push(cur); - records.push(record); - // 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())); + records.push((record, has_quotes)); + // Blank lines (such as the one a trailing line break leaves) are not records, but a + // quoted empty value is a record that clears a field. + let mut records = + records.into_iter().filter(|(r, has_quotes)| *has_quotes || !matches!(r.as_slice(), [only] if only.is_empty())).map(|(r, _)| r); let (Some(head), Some(row)) = (records.next(), records.next()) else { return Err(DataError::UnknownFormat) }; // 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. diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 524ed286..be747d77 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -379,6 +379,18 @@ fn one_column_csv_still_reads_its_quotes() { assert!(values(&dst)[0].1[0].starts_with("Ada \"the\", ") && values(&dst)[0].1[0].ends_with("Countess"), "{:?}", values(&dst)); } +#[test] +fn quoted_empty_csv_value_clears_a_field() { + for data in ["Name\n\"\"\n", "Name\r\n\"\"\r\n", "Name\n\"\"", "Name\n\n\"\"\n", "Name\n\"\"\nGrace\n"] { + let mut dst = one_field(); + set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap(); + let report = import(&mut dst, data.as_bytes()).unwrap(); + assert_eq!(report.fields, 1, "{data:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + assert!(values(&dst)[0].1.is_empty(), "{data:?}"); + } +} + #[test] fn multi_select_lists_round_trip_through_xml_csv_and_text() { let src = filled();