Merge PR #1525: Keep leading empty columns and rows when saving CSV/text

Conflicts:
- crates/xlsx/src/csv.rs: write_csv doc comment merged (this PR's A1 rectangle sentence, main's per-field cap wording)
- crates/engine/src/tests.rs: kept both appended tests
This commit is contained in:
aiongg
2026-10-22 02:35:17 +00:00
3 changed files with 20 additions and 11 deletions
+7
View File
@@ -3344,3 +3344,10 @@ fn csv_as_text_keeps_identifier_columns_as_written() {
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes());
}
#[test]
fn saving_csv_keeps_leading_empty_columns_and_rows() {
let wb = gridcraft_xlsx::read_csv(b",x\r\n\r\n,,y\r\n", &gridcraft_xlsx::CsvOptions::default()).unwrap();
let out = crate::io::save_bytes(&wb, "a.csv").unwrap();
assert_eq!(String::from_utf8(out).unwrap(), ",x,\r\n,,\r\n,,y\r\n");
}
+8 -7
View File
@@ -196,12 +196,13 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
Ok(wb)
}
/// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives
/// 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`]: two cells far apart make the used range, and so the
/// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas).
/// 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.
@@ -218,15 +219,15 @@ pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_te
|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 - r.start.row) + 1, u64::from(r.end.col - r.start.col) + 1);
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 r.start.row..=r.end.row {
for col in r.start.col..=r.end.col {
if col > r.start.col {
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));
+5 -4
View File
@@ -110,14 +110,15 @@ fn writing() {
Value::Array(_) => String::new(),
};
let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap();
assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n");
// The first cell is B2: the file starts at A1, so the leading empty row and column stay.
assert_eq!(out, ",,,\r\n,\"a,b\",\"q\"\"t\",\r\n,0.1,FALSE,\r\n,#DIV/0!,,\"x\ny\"\r\n");
let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap();
assert!(semi.starts_with("a,b;"));
assert!(semi.starts_with(";;;\r\n;a,b;"));
assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty());
// Round trip.
let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap();
assert_eq!(v(&back, 0, 0), Value::text("a,b"));
assert_eq!(v(&back, 2, 2), Value::text("x\ny"));
assert_eq!(v(&back, 1, 1), Value::text("a,b"));
assert_eq!(v(&back, 3, 3), Value::text("x\ny"));
}
#[test]