diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 09566d3be..7e949926f 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -843,34 +843,39 @@ fn import_table(doc: &mut Document, text: &str) -> Result { (name, vec![c.text().unwrap_or("").to_string()]) }) .collect() - } else { + } 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) }; - let split = |l: &str| -> Vec { - if l.contains('\t') { - l.split('\t').map(str::to_string).collect() - } else { - // CSV with quotes. - let mut out = Vec::new(); - let mut cur = String::new(); - let mut quoted = false; - let mut chars = l.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '"' if quoted && chars.peek() == Some(&'"') => { - cur.push('"'); - chars.next(); - } - '"' => quoted = !quoted, - ',' if !quoted => out.push(std::mem::take(&mut cur)), - c => cur.push(c), - } + head.split('\t').map(str::to_string).zip(row.split('\t')).map(|(k, v)| (k, vec![v.to_string()])).collect() + } else { + // CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks. + let mut records: Vec> = Vec::new(); + let mut record = Vec::new(); + let mut cur = String::new(); + let mut quoted = false; + let mut chars = t.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + cur.push('"'); + chars.next(); } - out.push(cur); - out + '"' => quoted = !quoted, + ',' 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)); + } + c => cur.push(c), } - }; - split(head).into_iter().zip(split(row)).map(|(k, v)| (k, vec![v])).collect() + } + 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())); + 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() }; apply_values(doc, &values, &mut report); Ok(report) diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index b2bc5ffb3..95d959224 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -152,6 +152,21 @@ fn form_data_as_xml_csv_and_text() { assert_eq!(values(&dst)[0].1, ["Lovelace, Ada"]); } +#[test] +fn csv_values_may_span_lines() { + // A quoted CSV value may hold line breaks (export_data writes multiline text that way): the + // record goes on to the closing quote, so later columns still import. + let mut dst = blank(); + let r = import(&mut dst, b"Name,Agree\r\n\"Ada \"\"the\"\"\r\nCountess\",Yes\r\n").unwrap(); + assert_eq!(r.fields, 2, "{r:?}"); + assert!(values(&dst)[0].1[0].starts_with("Ada \"the\"") && values(&dst)[0].1[0].ends_with("Countess"), "{:?}", values(&dst)); + assert_eq!(values(&dst)[1].1, ["Yes"]); + // A header without a record is still not a data file. + assert!(import(&mut blank(), b"Name,Agree\r\n").is_err()); + // An unterminated quote runs to the end of the file and is read leniently, as before. + assert!(import(&mut blank(), b"Name,Agree\r\n\"Ada, Yes\r\n").is_ok()); +} + #[test] fn bad_data_is_refused_with_a_reason() { let mut dst = blank();