mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Forms: CSV import keeps values that span lines (#4)
* Forms: CSV import keeps values that span lines A quoted CSV value may hold line breaks, and export_data writes multiline text fields that way. The importer split the file into lines before reading quotes, so such a value was cut at its first line break and every later column was lost (an export, then an import, of a form with a multiline field silently dropped data). CSV records now end only at unquoted line breaks; tab-delimited text is read as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: build CSV records without unwrap (never-panic rule) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: storybored379 <storybored379@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
storybored379
Brandon Thomas
parent
3908a03aa6
commit
096b515719
+29
-24
@@ -843,34 +843,39 @@ fn import_table(doc: &mut Document, text: &str) -> Result<Report, DataError> {
|
||||
(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<String> {
|
||||
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<String>> = 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user