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

211 lines
7.5 KiB
Rust
Raw 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::parse::parse_input;
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, Value};
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
use crate::{CsvOptions, IoError};
/// 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,
];
/// Decodes CSV bytes: UTF-8/UTF-16 BOMs, else UTF-8, else 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(_) => bytes
.iter()
.map(|&b| match b {
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.
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 {
record.push(std::mem::take(&mut field));
field_started_quoted = false;
} else if c == '\r' || c == '\n' {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
}
record.push(std::mem::take(&mut field));
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 {
record.push(field);
on_record(record);
}
}
pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, 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 mut wb = Workbook::new();
wb.date_system = opts.date_system;
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 p = parse_input(&f, opts.date_system);
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 }
} 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)
}
/// Writes the sheet's used range as CSV (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.
pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec<u8> {
let delim = if delimiter == 0 { ',' } else { delimiter as char };
let mut out = String::new();
let Some(r) = sheet.used_range() else { return Vec::new() };
for row in r.start.row..=r.end.row {
for col in r.start.col..=r.end.col {
if col > r.start.col {
out.push(delim);
}
let t = cell_text(CellRef::new(row, col));
if t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r') {
out.push('"');
out.push_str(&t.replace('"', "\"\""));
out.push('"');
} else {
out.push_str(&t);
}
}
out.push_str("\r\n");
}
out.into_bytes()
}