From 67be35564c23fcfd13759336e864e83692e8438a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:01:47 +0200 Subject: [PATCH] Keep leading empty columns and rows when saving CSV/text Found while testing crates/xlsx/src/csv.rs:204 (write_csv_limited, starts at sheet.used_range().start instead of A1) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests.rs | 7 +++++++ crates/xlsx/src/csv.rs | 15 ++++++++------- crates/xlsx/src/tests/csv_tests.rs | 9 +++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..3dab83cd5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3282,3 +3282,10 @@ fn paste_special_validation_moves_relative_references() { assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1"); assert!(ok(&mut s, "C3", "20")); } + +#[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"); +} diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 47486bfa2..6e707229b 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -187,12 +187,13 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { 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 as soon as the /// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in /// its place. @@ -209,15 +210,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)); diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index cc52072e7..a833c75be 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -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]