diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..55c8f1d81 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +} diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index b3d7f8060..9c631caea 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -196,12 +196,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 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)); diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index 325e8cc60..c113a0906 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]