Files
gridcraft/crates/xlsx/src/csv.rs

602 lines
25 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! CSV import and export (RFC 4180 with the usual real-world leniency).
use std::sync::Arc;
use gridcraft_core::date::{days_in_month_in, serial_from_ymd};
use gridcraft_core::parse::{Parsed, parse_input};
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, Value, col_to_letters};
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
use crate::{CsvOptions, IoError, ReadReport};
/// The largest CSV or tab-separated file written (bytes).
pub const MAX_CSV_BYTES: usize = 512 << 20;
/// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1).
const CP1252: [u32; 32] = [
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
];
/// Windows-1256 code points for bytes 0x80–0xFF (unicode.org `CP1256.TXT`).
pub(crate) const CP1256: [u32; 128] = [
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
0x200F, 0x06D2,
];
/// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252.
#[derive(Default)]
struct Cp1256Evidence {
/// High bytes that decode to different characters in the two code pages, apart from invisible
/// marks and ordinals after a digit.
differing: usize,
/// Of those, Arabic letters and Arabic punctuation (، ؛ ؟) in Windows-1256.
arabic: usize,
/// Arabic bytes next to an ASCII letter: Western accents sit inside Latin words, Arabic doesn't.
arabic_touching_latin: usize,
/// Accented Latin bytes the two code pages share, next to another high byte: Hebrew
/// Windows-1255 letters land there, Arabic and Western text rarely do.
shared_letters_in_runs: usize,
}
impl Cp1256Evidence {
fn of(bytes: &[u8]) -> Self {
let mut e = Self::default();
for (i, &b) in bytes.iter().enumerate() {
if b < 0x80 {
continue;
}
let before = i.checked_sub(1).and_then(|j| bytes.get(j)).copied();
let after = bytes.get(i + 1).copied();
let next_to = |pred: fn(u8) -> bool| before.is_some_and(pred) || after.is_some_and(pred);
if same_in_cp1252_and_cp1256(b) {
if is_shared_latin_letter(b) && next_to(|x| x >= 0x80) {
e.shared_letters_in_runs += 1;
}
continue;
}
if is_invisible_mark(b) || is_ordinal_after_digit(b, before) {
continue;
}
e.differing += 1;
if is_cp1256_arabic(b) {
e.arabic += 1;
if next_to(|x| x.is_ascii_alphabetic()) {
e.arabic_touching_latin += 1;
}
}
}
e
}
/// Thresholds chosen against month, day and language names from 95 glibc locales (Western in
/// Windows-1252, Arabic, Persian and Urdu in 1256, Hebrew and Yiddish in 1255) plus CSV samples.
fn is_arabic(&self) -> bool {
self.arabic >= 2
&& self.arabic * 10 >= self.differing * 9
&& self.arabic_touching_latin * 10 <= self.arabic * 3
&& self.shared_letters_in_runs * 20 <= self.arabic
}
}
fn same_in_cp1252_and_cp1256(b: u8) -> bool {
matches!(
b,
0x80 | 0x82..=0x89 | 0x8B..=0x8C | 0x91..=0x97 | 0x99 | 0x9B..=0x9C | 0xA0 | 0xA2..=0xA9 | 0xAB..=0xB9 | 0xBB..=0xBE | 0xD7 | 0xE0 | 0xE2 | 0xE7..=0xEB
| 0xEE..=0xEF | 0xF4 | 0xF7 | 0xF9 | 0xFB..=0xFC
)
}
/// ZWNJ, ZWJ, LRM and RLM: common inside Persian words and around Arabic cells, but no evidence
/// either way.
fn is_invisible_mark(b: u8) -> bool {
matches!(b, 0x9D | 0x9E | 0xFD | 0xFE)
}
/// `º` and `ª` after a digit (`1º`, `2ª`) are Western ordinals, though Windows-1256 reads the bytes
/// as `؛` and `ھ`; either way they are no evidence.
fn is_ordinal_after_digit(b: u8, before: Option<u8>) -> bool {
matches!(b, 0xAA | 0xBA) && before.is_some_and(|x| x.is_ascii_digit())
}
fn is_shared_latin_letter(b: u8) -> bool {
matches!(b, 0xE0 | 0xE2 | 0xE7..=0xEB | 0xEE..=0xEF | 0xF4 | 0xF9 | 0xFB..=0xFC)
}
fn is_cp1256_arabic(b: u8) -> bool {
matches!(
b,
0x81 | 0x8A | 0x8D..=0x90 | 0x98 | 0x9A | 0x9F | 0xA1 | 0xAA | 0xBA | 0xBF..=0xD6 | 0xD8..=0xDB | 0xDD..=0xDF | 0xE1 | 0xE3..=0xE6 | 0xEC..=0xED | 0xFF
)
}
/// Decodes CSV bytes with BOM-aware UTF-8 and UTF-16 handling. Without a BOM, valid UTF-8 is kept.
/// Other bytes decode as Windows-1256 when they read as Arabic (see [`Cp1256Evidence`]), else as
/// Windows-1252.
pub fn decode_text(bytes: &[u8]) -> String {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
}
let utf16 = |b: &[u8], le: bool| -> String {
let units = b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) });
char::decode_utf16(units).map(|r| r.unwrap_or('\u{FFFD}')).collect()
};
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
return utf16(rest, true);
}
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
return utf16(rest, false);
}
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => {
let use_windows_1256 = Cp1256Evidence::of(bytes).is_arabic();
bytes
.iter()
.map(|&b| match b {
0x80..=0xFF if use_windows_1256 => {
char::from_u32(CP1256.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}')
}
0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
_ => b as char,
})
.collect()
}
}
}
/// Picks comma, semicolon or tab: the candidate with the most consistent count per line
/// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins.
pub fn sniff_delimiter(text: &str, quote: char) -> u8 {
let mut best = (b',', 0usize, 0usize);
for d in [',', ';', '\t'] {
let mut counts = vec![];
let mut n = 0usize;
let mut in_q = false;
let mut blank = true;
for ch in text.chars().take(64 * 1024) {
if !in_q && ch == '\n' {
// Blank lines carry no delimiter evidence.
if !blank {
counts.push(n);
}
n = 0;
blank = true;
if counts.len() >= 20 {
break;
}
continue;
}
if ch != '\r' {
blank = false;
}
if ch == quote {
in_q = !in_q;
} else if !in_q && ch == d {
n += 1;
}
}
if !blank {
counts.push(n);
}
let min = counts.iter().copied().min().unwrap_or(0);
let total: usize = counts.iter().sum();
if min > 0 && (min, total) > (best.1, best.2) {
best = (d as u8, min, total);
}
}
best.0
}
/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are
/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field.
pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
let mut field = String::new();
let mut record: Vec<String> = vec![];
let mut in_q = false;
let mut chars = text.chars().peekable();
let mut field_started_quoted = false;
while let Some(c) = chars.next() {
if in_q {
if c == quote {
if chars.peek() == Some(&quote) {
field.push(quote);
chars.next();
} else {
in_q = false;
}
} else {
field.push(c);
}
continue;
}
if c == quote && field.is_empty() && !field_started_quoted {
in_q = true;
field_started_quoted = true;
} else if c == delim {
let f = std::mem::take(&mut field);
if record.len() < MAX_COLS as usize {
record.push(f);
}
field_started_quoted = false;
} else if c == '\r' || c == '\n' {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
}
let f = std::mem::take(&mut field);
if record.len() < MAX_COLS as usize {
record.push(f);
}
field_started_quoted = false;
if !on_record(std::mem::take(&mut record)) {
return;
}
} else {
field.push(c);
}
}
if !field.is_empty() || !record.is_empty() || field_started_quoted {
if record.len() < MAX_COLS as usize {
record.push(field);
}
on_record(record);
}
}
/// How many rows and bytes of a text file are read to decide each column's date order.
const DATE_SAMPLE_ROWS: usize = 200;
const DATE_SAMPLE_BYTES: usize = 1 << 20;
/// What a column's numeric dates say about the order of day and month.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DateRule {
/// No `d/m/y` or `d-m-y` value in the sample: read as typed input does.
NoDates,
/// A value reads only month first (`12/31/2026`), none reads only day first.
MonthFirst,
/// A value reads only day first (`31/05/2026`), none reads only month first.
DayFirst,
/// Every date is ambiguous (`03/04/2026`): keep the input parser's month-first reading.
Ambiguous,
/// Values read only day first and values read only month first: the dates stay text.
Conflict,
}
impl DateRule {
fn of(day_first: usize, month_first: usize, ambiguous: usize) -> Self {
match (day_first > 0, month_first > 0, ambiguous > 0) {
(true, true, _) => Self::Conflict,
(true, false, _) => Self::DayFirst,
(false, true, _) => Self::MonthFirst,
(false, false, true) => Self::Ambiguous,
(false, false, false) => Self::NoDates,
}
}
}
/// The evidence a column's sample gives for its date order.
#[derive(Clone, Default)]
struct DateEvidence {
day_first: usize,
month_first: usize,
ambiguous: usize,
day_first_example: Option<String>,
month_first_example: Option<String>,
ambiguous_example: Option<String>,
}
/// A numeric `d/m/y` or `d-m-y` field, with the serial it has read each way and its time of day.
struct NumericDate {
day_first: Option<f64>,
month_first: Option<f64>,
time: Option<f64>,
}
impl NumericDate {
/// The field under the column's rule: a date when that reading is valid, else text.
fn read(&self, field: &str, rule: DateRule) -> Parsed {
let serial = match rule {
DateRule::DayFirst => self.day_first,
DateRule::MonthFirst | DateRule::Ambiguous => self.month_first,
DateRule::Conflict | DateRule::NoDates => None,
};
match serial {
Some(serial) => Parsed {
value: Value::number(serial + self.time.unwrap_or(0.0)),
format: Some(if self.time.is_some() { "m/d/yyyy h:mm" } else { "m/d/yyyy" }),
},
None => Parsed { value: Value::text(field), format: None },
}
}
}
/// A numeric date written with `/` or `-`, with the serial it has read day first and month first.
/// `None` when the field isn't such a date (including ISO `yyyy-mm-dd`, month names and dots,
/// which read one way and keep the input parser).
fn numeric_date(field: &str, sys: DateSystem) -> Option<NumericDate> {
let text = field.trim();
let (date, time) = split_time(text, sys);
// Typed input reads a trailing comma after a date (`3/4/2026,`); do the same here.
let date = date.trim_end_matches(',');
let mut parts = date.split(['/', '-']);
let (a, b, c) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
let (a, b, c) = (a.trim(), b.trim(), c.trim());
// ISO dates are unambiguous; the input parser reads them. A three-digit year is not a
// date to typed input either.
if a.len() == 4 || c.len() == 3 {
return None;
}
let a = parse_digits(a)?;
let b = parse_digits(b)?;
let year = expand_year(c, parse_digits(c)?);
let day_first = valid_date(sys, year, b, a);
let month_first = valid_date(sys, year, a, b);
if day_first.is_none() && month_first.is_none() {
return None;
}
Some(NumericDate { day_first, month_first, time })
}
/// Splits a field into its date and a time of day, when the tail reads as a time. Both the first
/// and last space are tried, so `03/04/2026 10:30 AM` and `03 / 04 / 2026 10:30` both split.
fn split_time(text: &str, sys: DateSystem) -> (&str, Option<f64>) {
for at in [text.find(' '), text.rfind(' ')].into_iter().flatten() {
let (date, time) = text.split_at(at);
let time = time.trim();
// Times are short; a long tail is not one, and parsing it would be wasted work.
if time.len() > 40 {
continue;
}
if let Some(fraction) = time_of_day(time, sys) {
return (date.trim(), Some(fraction));
}
}
(text, None)
}
fn time_of_day(text: &str, sys: DateSystem) -> Option<f64> {
match parse_input(text, sys) {
Parsed { value: Value::Number(fraction), format: Some(code) } if code.contains('h') => Some(fraction),
_ => None,
}
}
fn parse_digits(s: &str) -> Option<i64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Expands a one- or two-digit year the way typed input does (`26` is 2026, `30` is 1930).
fn expand_year(text: &str, year: i64) -> i64 {
if text.len() <= 2 { if year < 30 { 2000 + year } else { 1900 + year } } else { year }
}
/// The serial for a date the input parser accepts, or `None`.
fn valid_date(sys: DateSystem, year: i64, month: i64, day: i64) -> Option<f64> {
if !(1..=12).contains(&month) || day < 1 || year < 1900 {
return None;
}
let year = i32::try_from(year).ok()?;
let month = u32::try_from(month).ok()?;
if day > i64::from(days_in_month_in(sys, year, month)) {
return None;
}
serial_from_ymd(sys, i64::from(year), i64::from(month), day)
}
/// The first records of the file, bounded by rows and bytes, for date-order inference.
fn sample_records(text: &str, delim: char, quote: char) -> Vec<Vec<String>> {
let mut cut = text.len().min(DATE_SAMPLE_BYTES);
while !text.is_char_boundary(cut) {
cut -= 1;
}
let truncated = cut < text.len();
let sample = text.get(..cut).unwrap_or_default();
let mut rows = Vec::new();
parse_records(sample, delim, quote, |record| {
rows.push(record);
rows.len() < DATE_SAMPLE_ROWS
});
// A cut through the last record leaves a partial field; it says nothing reliable.
if truncated {
rows.pop();
}
rows
}
/// The date rule for each column, from the sample.
fn infer_date_rules(rows: &[Vec<String>], sys: DateSystem) -> Vec<(DateRule, DateEvidence)> {
let width = rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_COLS as usize);
let mut columns = vec![DateEvidence::default(); width];
for row in rows {
for (col, field) in row.iter().enumerate() {
let Some(column) = columns.get_mut(col) else { continue };
let Some(date) = numeric_date(field, sys) else { continue };
let example = || field.trim().chars().take(40).collect::<String>();
match (date.day_first.is_some(), date.month_first.is_some()) {
(true, true) => {
column.ambiguous += 1;
column.ambiguous_example.get_or_insert_with(example);
}
(true, false) => {
column.day_first += 1;
column.day_first_example.get_or_insert_with(example);
}
(false, true) => {
column.month_first += 1;
column.month_first_example.get_or_insert_with(example);
}
(false, false) => {}
}
}
}
columns.into_iter().map(|c| (DateRule::of(c.day_first, c.month_first, c.ambiguous), c)).collect()
}
/// Warns when the sample could not choose a date order: an all-ambiguous column keeps the
/// month-first reading, and a column with both kinds keeps its dates as text.
fn date_warnings(columns: &[(DateRule, DateEvidence)], report: &mut ReadReport) {
for (col, (rule, evidence)) in columns.iter().enumerate() {
match rule {
DateRule::Ambiguous => report.warn(format!(
"column {}: every date is ambiguous, so it was read month first ({})",
col_to_letters(col as u32),
evidence.ambiguous_example.as_deref().unwrap_or("a date")
)),
DateRule::Conflict => report.warn(format!(
"column {}: it holds both day-first and month-first dates ({} and {}), so its dates stay text",
col_to_letters(col as u32),
evidence.day_first_example.as_deref().unwrap_or("a day-first date"),
evidence.month_first_example.as_deref().unwrap_or("a month-first date")
)),
DateRule::NoDates | DateRule::MonthFirst | DateRule::DayFirst => {}
}
}
}
/// Reads one field with the column's date rule; other values go through typed input.
fn read_field(field: &str, sys: DateSystem, rule: DateRule) -> Parsed {
match numeric_date(field, sys) {
Some(date) if rule != DateRule::NoDates => date.read(field, rule),
_ => parse_input(field, sys),
}
}
pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
read_csv_with_report(bytes, opts).map(|(wb, _)| wb)
}
/// [`read_csv`], with the warnings an open shows: a column whose date order had to be guessed,
/// or whose dates stayed text because it holds both orders.
pub fn read_csv_with_report(bytes: &[u8], opts: &CsvOptions) -> Result<(Workbook, ReadReport), IoError> {
let text = decode_text(bytes);
let quote = if opts.quote == 0 { '"' } else { opts.quote as char };
let delim = if opts.delimiter == 0 { sniff_delimiter(&text, quote) } else { opts.delimiter } as char;
if delim == quote {
return Err(IoError::Format("the delimiter and quote character must differ".into()));
}
let date_columns = if opts.parse_values { infer_date_rules(&sample_records(&text, delim, quote), opts.date_system) } else { vec![] };
let mut report = ReadReport::default();
date_warnings(&date_columns, &mut report);
let mut wb = Workbook::new();
wb.date_system = opts.date_system;
wb.csv_delimiter = u8::try_from(delim).ok();
let mut sheet = Sheet::new("Sheet1");
let mut styles = std::mem::take(&mut wb.styles);
let mut fmt_cache: Vec<(&'static str, StyleId)> = vec![];
let mut row: u32 = 0;
parse_records(&text, delim, quote, |rec| {
if row >= MAX_ROWS {
return false;
}
// A blank line is an empty row.
for (col, f) in rec.into_iter().enumerate() {
if col as u32 >= MAX_COLS {
break;
}
if f.is_empty() {
continue;
}
let pos = CellRef::new(row, col as u32);
let cell = if opts.parse_values {
let rule = date_columns.get(col).map_or(DateRule::NoDates, |(rule, _)| *rule);
let p = read_field(&f, opts.date_system, rule);
let style = match p.format {
Some(code) => match fmt_cache.iter().find(|(c, _)| *c == code) {
Some((_, id)) => *id,
None => {
let id = styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code));
fmt_cache.push((code, id));
id
}
},
None => StyleId::DEFAULT,
};
Cell { value: p.value, formula: None, style, py: None }
} else {
Cell::value(Value::Text(Arc::from(f)))
};
sheet.cells.set(pos, cell);
}
row += 1;
true
});
wb.styles = styles;
wb.sheets = vec![Arc::new(sheet)];
Ok((wb, report))
}
/// Writes the sheet as CSV from A1 to the end of its used range, so leading empty columns and rows
/// stay as empty fields and every cell keeps its position (CRLF line ends, UTF-8 without BOM). `cell_text` gives
/// each cell's field text: the caller decides how values look (Save As CSV writes them as
/// displayed, number formats applied); quoting is done here.
///
/// The output is capped at [`MAX_CSV_BYTES`]: a cell far from A1 makes the file as big as the whole
/// rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas).
/// An export that would pass the cap is refused before anything is allocated, or at the first
/// field or line end that takes it past the cap (a field is measured before it is written), with
/// [`IoError::TooLarge`]; accepted exports keep every field in its place.
pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text)
}
/// [`write_csv`] with the output cap `limit` (bytes) given.
pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
let delim = if delimiter == 0 { ',' } else { delimiter as char };
let mut out = String::new();
let Some(r) = sheet.used_range() else { return Ok(Vec::new()) };
let too_large =
|what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20));
// Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower
// bound on the output that needs no cell text.
let (rows, cols) = (u64::from(r.end.row) + 1, u64::from(r.end.col) + 1);
let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2);
let least = rows.saturating_mul(per_row);
if least > limit as u64 {
return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20)));
}
for row in 0..=r.end.row {
for col in 0..=r.end.col {
if col > 0 {
out.push(delim);
}
let t = cell_text(CellRef::new(row, col));
let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r');
// The exact size of the field as written (the delimiter was pushed above), so a row
// never grows past the cap unseen and no doubled-quote copy of the text is built.
let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() };
if out.len().saturating_add(field_len) > limit {
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
}
if quoted {
out.push('"');
for ch in t.chars() {
if ch == '"' {
out.push('"');
}
out.push(ch);
}
out.push('"');
} else {
out.push_str(&t);
}
}
out.push_str("\r\n");
if out.len() > limit {
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
}
}
Ok(out.into_bytes())
}