Skip to main content

ravenrs_sheet/xlsx/
csv.rs

1//! CSV import and export (RFC 4180 with the usual real-world leniency).
2
3use std::sync::Arc;
4
5use crate::base::date::{days_in_month_in, serial_from_ymd};
6use crate::base::parse::{Parsed, parse_input};
7use crate::base::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, Value, col_to_letters};
8use crate::model::{Cell, NumFmt, Sheet, StyleId, Workbook};
9
10use crate::xlsx::{CsvOptions, IoError, ReadReport};
11
12/// The largest CSV or tab-separated file written (bytes).
13pub const MAX_CSV_BYTES: usize = 512 << 20;
14
15/// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1).
16const CP1252: [u32; 32] = [
17    0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
18    0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
19];
20
21/// Windows-1256 code points for bytes 0x80–0xFF (unicode.org `CP1256.TXT`).
22pub(crate) const CP1256: [u32; 128] = [
23    0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
24    0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
25    0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
26    0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
27    0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
28    0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
29    0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
30    0x200F, 0x06D2,
31];
32
33/// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252.
34#[derive(Default)]
35struct Cp1256Evidence {
36    /// High bytes that decode to different characters in the two code pages, apart from invisible
37    /// marks and ordinals after a digit.
38    differing: usize,
39    /// Of those, Arabic letters and Arabic punctuation (، ؛ ؟) in Windows-1256.
40    arabic: usize,
41    /// Arabic bytes next to an ASCII letter: Western accents sit inside Latin words, Arabic doesn't.
42    arabic_touching_latin: usize,
43    /// Accented Latin bytes the two code pages share, next to another high byte: Hebrew
44    /// Windows-1255 letters land there, Arabic and Western text rarely do.
45    shared_letters_in_runs: usize,
46}
47
48impl Cp1256Evidence {
49    fn of(bytes: &[u8]) -> Self {
50        let mut e = Self::default();
51        for (i, &b) in bytes.iter().enumerate() {
52            if b < 0x80 {
53                continue;
54            }
55            let before = i.checked_sub(1).and_then(|j| bytes.get(j)).copied();
56            let after = bytes.get(i + 1).copied();
57            let next_to = |pred: fn(u8) -> bool| before.is_some_and(pred) || after.is_some_and(pred);
58            if same_in_cp1252_and_cp1256(b) {
59                if is_shared_latin_letter(b) && next_to(|x| x >= 0x80) {
60                    e.shared_letters_in_runs += 1;
61                }
62                continue;
63            }
64            if is_invisible_mark(b) || is_ordinal_after_digit(b, before) {
65                continue;
66            }
67            e.differing += 1;
68            if is_cp1256_arabic(b) {
69                e.arabic += 1;
70                if next_to(|x| x.is_ascii_alphabetic()) {
71                    e.arabic_touching_latin += 1;
72                }
73            }
74        }
75        e
76    }
77
78    /// Thresholds chosen against month, day and language names from 95 glibc locales (Western in
79    /// Windows-1252, Arabic, Persian and Urdu in 1256, Hebrew and Yiddish in 1255) plus CSV samples.
80    fn is_arabic(&self) -> bool {
81        self.arabic >= 2
82            && self.arabic * 10 >= self.differing * 9
83            && self.arabic_touching_latin * 10 <= self.arabic * 3
84            && self.shared_letters_in_runs * 20 <= self.arabic
85    }
86}
87
88fn same_in_cp1252_and_cp1256(b: u8) -> bool {
89    matches!(
90        b,
91        0x80 | 0x82..=0x89 | 0x8B..=0x8C | 0x91..=0x97 | 0x99 | 0x9B..=0x9C | 0xA0 | 0xA2..=0xA9 | 0xAB..=0xB9 | 0xBB..=0xBE | 0xD7 | 0xE0 | 0xE2 | 0xE7..=0xEB
92            | 0xEE..=0xEF | 0xF4 | 0xF7 | 0xF9 | 0xFB..=0xFC
93    )
94}
95
96/// ZWNJ, ZWJ, LRM and RLM: common inside Persian words and around Arabic cells, but no evidence
97/// either way.
98fn is_invisible_mark(b: u8) -> bool {
99    matches!(b, 0x9D | 0x9E | 0xFD | 0xFE)
100}
101
102/// `º` and `ª` after a digit (`1º`, `2ª`) are Western ordinals, though Windows-1256 reads the bytes
103/// as `؛` and `ھ`; either way they are no evidence.
104fn is_ordinal_after_digit(b: u8, before: Option<u8>) -> bool {
105    matches!(b, 0xAA | 0xBA) && before.is_some_and(|x| x.is_ascii_digit())
106}
107
108fn is_shared_latin_letter(b: u8) -> bool {
109    matches!(b, 0xE0 | 0xE2 | 0xE7..=0xEB | 0xEE..=0xEF | 0xF4 | 0xF9 | 0xFB..=0xFC)
110}
111
112fn is_cp1256_arabic(b: u8) -> bool {
113    matches!(
114        b,
115        0x81 | 0x8A | 0x8D..=0x90 | 0x98 | 0x9A | 0x9F | 0xA1 | 0xAA | 0xBA | 0xBF..=0xD6 | 0xD8..=0xDB | 0xDD..=0xDF | 0xE1 | 0xE3..=0xE6 | 0xEC..=0xED | 0xFF
116    )
117}
118
119/// Decodes CSV bytes with BOM-aware UTF-8 and UTF-16 handling. Without a BOM, valid UTF-8 is kept.
120/// Other bytes decode as Windows-1256 when they read as Arabic (see [`Cp1256Evidence`]), else as
121/// Windows-1252.
122pub fn decode_text(bytes: &[u8]) -> String {
123    if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
124        return String::from_utf8_lossy(rest).into_owned();
125    }
126    let utf16 = |b: &[u8], le: bool| -> String {
127        let units = b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) });
128        char::decode_utf16(units).map(|r| r.unwrap_or('\u{FFFD}')).collect()
129    };
130    if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
131        return utf16(rest, true);
132    }
133    if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
134        return utf16(rest, false);
135    }
136    match std::str::from_utf8(bytes) {
137        Ok(s) => s.to_string(),
138        Err(_) => {
139            let use_windows_1256 = Cp1256Evidence::of(bytes).is_arabic();
140            bytes
141                .iter()
142                .map(|&b| match b {
143                    0x80..=0xFF if use_windows_1256 => {
144                        char::from_u32(CP1256.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}')
145                    }
146                    0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
147                    _ => b as char,
148                })
149                .collect()
150        }
151    }
152}
153
154/// Picks comma, semicolon or tab: the candidate with the most consistent count per line
155/// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins.
156pub fn sniff_delimiter(text: &str, quote: char) -> u8 {
157    let mut best = (b',', 0usize, 0usize);
158    for d in [',', ';', '\t'] {
159        let mut counts = vec![];
160        let mut n = 0usize;
161        let mut in_q = false;
162        let mut blank = true;
163        for ch in text.chars().take(64 * 1024) {
164            if !in_q && ch == '\n' {
165                // Blank lines carry no delimiter evidence.
166                if !blank {
167                    counts.push(n);
168                }
169                n = 0;
170                blank = true;
171                if counts.len() >= 20 {
172                    break;
173                }
174                continue;
175            }
176            if ch != '\r' {
177                blank = false;
178            }
179            if ch == quote {
180                in_q = !in_q;
181            } else if !in_q && ch == d {
182                n += 1;
183            }
184        }
185        if !blank {
186            counts.push(n);
187        }
188        let min = counts.iter().copied().min().unwrap_or(0);
189        let total: usize = counts.iter().sum();
190        if min > 0 && (min, total) > (best.1, best.2) {
191            best = (d as u8, min, total);
192        }
193    }
194    best.0
195}
196
197/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are
198/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field.
199pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
200    let mut field = String::new();
201    let mut record: Vec<String> = vec![];
202    let mut in_q = false;
203    let mut chars = text.chars().peekable();
204    let mut field_started_quoted = false;
205    while let Some(c) = chars.next() {
206        if in_q {
207            if c == quote {
208                if chars.peek() == Some(&quote) {
209                    field.push(quote);
210                    chars.next();
211                } else {
212                    in_q = false;
213                }
214            } else {
215                field.push(c);
216            }
217            continue;
218        }
219        if c == quote && field.is_empty() && !field_started_quoted {
220            in_q = true;
221            field_started_quoted = true;
222        } else if c == delim {
223            let f = std::mem::take(&mut field);
224            if record.len() < MAX_COLS as usize {
225                record.push(f);
226            }
227            field_started_quoted = false;
228        } else if c == '\r' || c == '\n' {
229            if c == '\r' && chars.peek() == Some(&'\n') {
230                chars.next();
231            }
232            let f = std::mem::take(&mut field);
233            if record.len() < MAX_COLS as usize {
234                record.push(f);
235            }
236            field_started_quoted = false;
237            if !on_record(std::mem::take(&mut record)) {
238                return;
239            }
240        } else {
241            field.push(c);
242        }
243    }
244    if !field.is_empty() || !record.is_empty() || field_started_quoted {
245        if record.len() < MAX_COLS as usize {
246            record.push(field);
247        }
248        on_record(record);
249    }
250}
251
252/// How many rows and bytes of a text file are read to decide each column's date order.
253const DATE_SAMPLE_ROWS: usize = 200;
254const DATE_SAMPLE_BYTES: usize = 1 << 20;
255
256/// What a column's numeric dates say about the order of day and month.
257#[derive(Clone, Copy, Debug, PartialEq, Eq)]
258enum DateRule {
259    /// No `d/m/y` or `d-m-y` value in the sample: read as typed input does.
260    NoDates,
261    /// A value reads only month first (`12/31/2026`), none reads only day first.
262    MonthFirst,
263    /// A value reads only day first (`31/05/2026`), none reads only month first.
264    DayFirst,
265    /// Every date is ambiguous (`03/04/2026`): keep the input parser's month-first reading.
266    Ambiguous,
267    /// Values read only day first and values read only month first: the dates stay text.
268    Conflict,
269}
270
271impl DateRule {
272    fn of(day_first: usize, month_first: usize, ambiguous: usize) -> Self {
273        match (day_first > 0, month_first > 0, ambiguous > 0) {
274            (true, true, _) => Self::Conflict,
275            (true, false, _) => Self::DayFirst,
276            (false, true, _) => Self::MonthFirst,
277            (false, false, true) => Self::Ambiguous,
278            (false, false, false) => Self::NoDates,
279        }
280    }
281}
282
283/// The evidence a column's sample gives for its date order.
284#[derive(Clone, Default)]
285struct DateEvidence {
286    day_first: usize,
287    month_first: usize,
288    ambiguous: usize,
289    day_first_example: Option<String>,
290    month_first_example: Option<String>,
291    ambiguous_example: Option<String>,
292}
293
294/// A numeric `d/m/y` or `d-m-y` field, with the serial it has read each way and its time of day.
295struct NumericDate {
296    day_first: Option<f64>,
297    month_first: Option<f64>,
298    time: Option<f64>,
299}
300
301impl NumericDate {
302    /// The field under the column's rule: a date when that reading is valid, else text.
303    fn read(&self, field: &str, rule: DateRule) -> Parsed {
304        let serial = match rule {
305            DateRule::DayFirst => self.day_first,
306            DateRule::MonthFirst | DateRule::Ambiguous => self.month_first,
307            DateRule::Conflict | DateRule::NoDates => None,
308        };
309        match serial {
310            Some(serial) => Parsed {
311                value: Value::number(serial + self.time.unwrap_or(0.0)),
312                format: Some(if self.time.is_some() { "m/d/yyyy h:mm" } else { "m/d/yyyy" }),
313            },
314            None => Parsed { value: Value::text(field), format: None },
315        }
316    }
317}
318
319/// A numeric date written with `/` or `-`, with the serial it has read day first and month first.
320/// `None` when the field isn't such a date (including ISO `yyyy-mm-dd`, month names and dots,
321/// which read one way and keep the input parser).
322fn numeric_date(field: &str, sys: DateSystem) -> Option<NumericDate> {
323    let text = field.trim();
324    let (date, time) = split_time(text, sys);
325    // Typed input reads a trailing comma after a date (`3/4/2026,`); do the same here.
326    let date = date.trim_end_matches(',');
327    let mut parts = date.split(['/', '-']);
328    let (a, b, c) = (parts.next()?, parts.next()?, parts.next()?);
329    if parts.next().is_some() {
330        return None;
331    }
332    let (a, b, c) = (a.trim(), b.trim(), c.trim());
333    // ISO dates are unambiguous; the input parser reads them. A three-digit year is not a
334    // date to typed input either.
335    if a.len() == 4 || c.len() == 3 {
336        return None;
337    }
338    let a = parse_digits(a)?;
339    let b = parse_digits(b)?;
340    let year = expand_year(c, parse_digits(c)?);
341    let day_first = valid_date(sys, year, b, a);
342    let month_first = valid_date(sys, year, a, b);
343    if day_first.is_none() && month_first.is_none() {
344        return None;
345    }
346    Some(NumericDate { day_first, month_first, time })
347}
348
349/// Splits a field into its date and a time of day, when the tail reads as a time. Both the first
350/// and last space are tried, so `03/04/2026 10:30 AM` and `03 / 04 / 2026 10:30` both split.
351fn split_time(text: &str, sys: DateSystem) -> (&str, Option<f64>) {
352    for at in [text.find(' '), text.rfind(' ')].into_iter().flatten() {
353        let (date, time) = text.split_at(at);
354        let time = time.trim();
355        // Times are short; a long tail is not one, and parsing it would be wasted work.
356        if time.len() > 40 {
357            continue;
358        }
359        if let Some(fraction) = time_of_day(time, sys) {
360            return (date.trim(), Some(fraction));
361        }
362    }
363    (text, None)
364}
365
366fn time_of_day(text: &str, sys: DateSystem) -> Option<f64> {
367    match parse_input(text, sys) {
368        Parsed { value: Value::Number(fraction), format: Some(code) } if code.contains('h') => Some(fraction),
369        _ => None,
370    }
371}
372
373fn parse_digits(s: &str) -> Option<i64> {
374    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
375        return None;
376    }
377    s.parse().ok()
378}
379
380/// Expands a one- or two-digit year the way typed input does (`26` is 2026, `30` is 1930).
381fn expand_year(text: &str, year: i64) -> i64 {
382    if text.len() <= 2 { if year < 30 { 2000 + year } else { 1900 + year } } else { year }
383}
384
385/// The serial for a date the input parser accepts, or `None`.
386fn valid_date(sys: DateSystem, year: i64, month: i64, day: i64) -> Option<f64> {
387    if !(1..=12).contains(&month) || day < 1 || year < 1900 {
388        return None;
389    }
390    let year = i32::try_from(year).ok()?;
391    let month = u32::try_from(month).ok()?;
392    if day > i64::from(days_in_month_in(sys, year, month)) {
393        return None;
394    }
395    serial_from_ymd(sys, i64::from(year), i64::from(month), day)
396}
397
398/// The first records of the file, bounded by rows and bytes, for date-order inference.
399fn sample_records(text: &str, delim: char, quote: char) -> Vec<Vec<String>> {
400    let mut cut = text.len().min(DATE_SAMPLE_BYTES);
401    while !text.is_char_boundary(cut) {
402        cut -= 1;
403    }
404    let truncated = cut < text.len();
405    let sample = text.get(..cut).unwrap_or_default();
406    let mut rows = Vec::new();
407    parse_records(sample, delim, quote, |record| {
408        rows.push(record);
409        rows.len() < DATE_SAMPLE_ROWS
410    });
411    // A cut through the last record leaves a partial field; it says nothing reliable.
412    if truncated {
413        rows.pop();
414    }
415    rows
416}
417
418/// The date rule for each column, from the sample.
419fn infer_date_rules(rows: &[Vec<String>], sys: DateSystem) -> Vec<(DateRule, DateEvidence)> {
420    let width = rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_COLS as usize);
421    let mut columns = vec![DateEvidence::default(); width];
422    for row in rows {
423        for (col, field) in row.iter().enumerate() {
424            let Some(column) = columns.get_mut(col) else { continue };
425            let Some(date) = numeric_date(field, sys) else { continue };
426            let example = || field.trim().chars().take(40).collect::<String>();
427            match (date.day_first.is_some(), date.month_first.is_some()) {
428                (true, true) => {
429                    column.ambiguous += 1;
430                    column.ambiguous_example.get_or_insert_with(example);
431                }
432                (true, false) => {
433                    column.day_first += 1;
434                    column.day_first_example.get_or_insert_with(example);
435                }
436                (false, true) => {
437                    column.month_first += 1;
438                    column.month_first_example.get_or_insert_with(example);
439                }
440                (false, false) => {}
441            }
442        }
443    }
444    columns.into_iter().map(|c| (DateRule::of(c.day_first, c.month_first, c.ambiguous), c)).collect()
445}
446
447/// Warns when the sample could not choose a date order: an all-ambiguous column keeps the
448/// month-first reading, and a column with both kinds keeps its dates as text.
449fn date_warnings(columns: &[(DateRule, DateEvidence)], report: &mut ReadReport) {
450    for (col, (rule, evidence)) in columns.iter().enumerate() {
451        match rule {
452            DateRule::Ambiguous => report.warn(format!(
453                "column {}: every date is ambiguous, so it was read month first ({})",
454                col_to_letters(col as u32),
455                evidence.ambiguous_example.as_deref().unwrap_or("a date")
456            )),
457            DateRule::Conflict => report.warn(format!(
458                "column {}: it holds both day-first and month-first dates ({} and {}), so its dates stay text",
459                col_to_letters(col as u32),
460                evidence.day_first_example.as_deref().unwrap_or("a day-first date"),
461                evidence.month_first_example.as_deref().unwrap_or("a month-first date")
462            )),
463            DateRule::NoDates | DateRule::MonthFirst | DateRule::DayFirst => {}
464        }
465    }
466}
467
468/// Reads one field with the column's date rule; other values go through typed input.
469fn read_field(field: &str, sys: DateSystem, rule: DateRule) -> Parsed {
470    match numeric_date(field, sys) {
471        Some(date) if rule != DateRule::NoDates => date.read(field, rule),
472        _ => parse_input(field, sys),
473    }
474}
475
476pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
477    read_csv_with_report(bytes, opts).map(|(wb, _)| wb)
478}
479
480/// [`read_csv`], with the warnings an open shows: a column whose date order had to be guessed,
481/// or whose dates stayed text because it holds both orders.
482pub fn read_csv_with_report(bytes: &[u8], opts: &CsvOptions) -> Result<(Workbook, ReadReport), IoError> {
483    let text = decode_text(bytes);
484    let quote = if opts.quote == 0 { '"' } else { opts.quote as char };
485    let delim = if opts.delimiter == 0 { sniff_delimiter(&text, quote) } else { opts.delimiter } as char;
486    if delim == quote {
487        return Err(IoError::Format("the delimiter and quote character must differ".into()));
488    }
489    let date_columns = if opts.parse_values { infer_date_rules(&sample_records(&text, delim, quote), opts.date_system) } else { vec![] };
490    let mut report = ReadReport::default();
491    date_warnings(&date_columns, &mut report);
492    let mut wb = Workbook::new();
493    wb.date_system = opts.date_system;
494    wb.csv_delimiter = u8::try_from(delim).ok();
495    let mut sheet = Sheet::new("Sheet1");
496    let mut styles = std::mem::take(&mut wb.styles);
497    let mut fmt_cache: Vec<(&'static str, StyleId)> = vec![];
498    let mut row: u32 = 0;
499    parse_records(&text, delim, quote, |rec| {
500        if row >= MAX_ROWS {
501            return false;
502        }
503        // A blank line is an empty row.
504        for (col, f) in rec.into_iter().enumerate() {
505            if col as u32 >= MAX_COLS {
506                break;
507            }
508            if f.is_empty() {
509                continue;
510            }
511            let pos = CellRef::new(row, col as u32);
512            let cell = if opts.parse_values {
513                let rule = date_columns.get(col).map_or(DateRule::NoDates, |(rule, _)| *rule);
514                let p = read_field(&f, opts.date_system, rule);
515                let style = match p.format {
516                    Some(code) => match fmt_cache.iter().find(|(c, _)| *c == code) {
517                        Some((_, id)) => *id,
518                        None => {
519                            let id = styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code));
520                            fmt_cache.push((code, id));
521                            id
522                        }
523                    },
524                    None => StyleId::DEFAULT,
525                };
526                Cell { value: p.value, formula: None, style, py: None }
527            } else {
528                Cell::value(Value::Text(Arc::from(f)))
529            };
530            sheet.cells.set(pos, cell);
531        }
532        row += 1;
533        true
534    });
535    wb.styles = styles;
536    wb.sheets = vec![Arc::new(sheet)];
537    Ok((wb, report))
538}
539
540/// Writes the sheet as CSV from A1 to the end of its used range, so leading empty columns and rows
541/// stay as empty fields and every cell keeps its position (CRLF line ends, UTF-8 without BOM). `cell_text` gives
542/// each cell's field text: the caller decides how values look (Save As CSV writes them as
543/// displayed, number formats applied); quoting is done here.
544///
545/// The output is capped at [`MAX_CSV_BYTES`]: a cell far from A1 makes the file as big as the whole
546/// rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas).
547/// An export that would pass the cap is refused before anything is allocated, or at the first
548/// field or line end that takes it past the cap (a field is measured before it is written), with
549/// [`IoError::TooLarge`]; accepted exports keep every field in its place.
550pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
551    write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text)
552}
553
554/// [`write_csv`] with the output cap `limit` (bytes) given.
555pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
556    let delim = if delimiter == 0 { ',' } else { delimiter as char };
557    let mut out = String::new();
558    let Some(r) = sheet.used_range() else { return Ok(Vec::new()) };
559    let too_large =
560        |what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20));
561    // Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower
562    // bound on the output that needs no cell text.
563    let (rows, cols) = (u64::from(r.end.row) + 1, u64::from(r.end.col) + 1);
564    let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2);
565    let least = rows.saturating_mul(per_row);
566    if least > limit as u64 {
567        return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20)));
568    }
569    for row in 0..=r.end.row {
570        for col in 0..=r.end.col {
571            if col > 0 {
572                out.push(delim);
573            }
574            let t = cell_text(CellRef::new(row, col));
575            let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r');
576            // The exact size of the field as written (the delimiter was pushed above), so a row
577            // never grows past the cap unseen and no doubled-quote copy of the text is built.
578            let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() };
579            if out.len().saturating_add(field_len) > limit {
580                return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
581            }
582            if quoted {
583                out.push('"');
584                for ch in t.chars() {
585                    if ch == '"' {
586                        out.push('"');
587                    }
588                    out.push(ch);
589                }
590                out.push('"');
591            } else {
592                out.push_str(&t);
593            }
594        }
595        out.push_str("\r\n");
596        if out.len() > limit {
597            return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
598        }
599    }
600    Ok(out.into_bytes())
601}