Merge pull request #853 from Silvertree2010:fix/csv-export-budget
This commit is contained in:
commit
a6f8818794
|
|
@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) |
|
||||
| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) |
|
||||
| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) |
|
||||
| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) |
|
||||
|
|
@ -186,6 +187,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) |
|
||||
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
||||
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
|
|
|
|||
|
|
@ -61,3 +61,7 @@ sync_github_name = true
|
|||
real_name = "James Symes"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
|||
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
|
||||
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()),
|
||||
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()),
|
||||
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
|||
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
|
||||
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
|
||||
/// transferred to other file formats*): number formats applied, never column-width `#####`.
|
||||
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
|
||||
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
|
||||
gridcraft_xlsx::write_csv(sh, delimiter, |c| {
|
||||
// General's grid-width formatting rounds large identifiers to scientific notation.
|
||||
// Delimited files have no column width, so preserve the whole numeric value instead.
|
||||
|
|
@ -201,6 +201,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
|
|||
crate::display::value_text(wb, sh, c)
|
||||
}
|
||||
})
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as
|
||||
//! big as the whole rectangle between them. A refused export is an error and writes nothing.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn two_far_cells() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() {
|
||||
let mut s = two_far_cells();
|
||||
for format in ["csv", "tsv"] {
|
||||
let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string();
|
||||
assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}");
|
||||
}
|
||||
// Save As and Export write no file, and the document stays as it was.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("big.csv");
|
||||
let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string();
|
||||
assert!(err.contains("over the limit"), "{err}");
|
||||
assert!(!path.exists(), "nothing is written for a refused export");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
// Other formats are unaffected, and so is a small export from the same session.
|
||||
assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok());
|
||||
s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap();
|
||||
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_below_the_cap_keep_their_positions() {
|
||||
for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap();
|
||||
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}");
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@ use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
|
|||
|
||||
use crate::{CsvOptions, IoError};
|
||||
|
||||
/// The largest CSV or tab-separated file written (bytes).
|
||||
pub const MAX_CSV_BYTES: usize = 512 << 20;
|
||||
|
||||
/// 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,
|
||||
|
|
@ -187,10 +190,31 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
|
|||
/// 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> {
|
||||
///
|
||||
/// 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).
|
||||
/// 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.
|
||||
pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
|
||||
write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text)
|
||||
}
|
||||
|
||||
/// [`write_csv`] with the output cap `limit` (bytes) given.
|
||||
pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
|
||||
let delim = if delimiter == 0 { ',' } else { delimiter as char };
|
||||
let mut out = String::new();
|
||||
let Some(r) = sheet.used_range() else { return Vec::new() };
|
||||
let Some(r) = sheet.used_range() else { return Ok(Vec::new()) };
|
||||
let too_large =
|
||||
|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 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 {
|
||||
|
|
@ -206,6 +230,9 @@ pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef
|
|||
}
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
if out.len() > limit {
|
||||
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
|
||||
}
|
||||
out.into_bytes()
|
||||
}
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ use gridcraft_core::DateSystem;
|
|||
use gridcraft_model::Workbook;
|
||||
|
||||
pub use crypto::{DecryptError, check_encryption, decrypt};
|
||||
pub use csv::{read_csv, write_csv};
|
||||
pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited};
|
||||
pub use ods::read_ods;
|
||||
pub use xls::read_xls;
|
||||
pub use xlsb::read_xlsb;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, Value};
|
|||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::csv::{decode_text, sniff_delimiter};
|
||||
use crate::{CsvOptions, Format, read_csv, sniff, write_csv};
|
||||
use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited};
|
||||
|
||||
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
|
||||
wb.sheet(0).unwrap().value(CellRef::new(r, c))
|
||||
|
|
@ -109,11 +109,11 @@ fn writing() {
|
|||
Value::Error(e) => e.as_str().into(),
|
||||
Value::Array(_) => String::new(),
|
||||
};
|
||||
let out = String::from_utf8(write_csv(sh, b',', raw)).unwrap();
|
||||
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");
|
||||
let semi = String::from_utf8(write_csv(sh, b';', raw)).unwrap();
|
||||
let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap();
|
||||
assert!(semi.starts_with("a,b;"));
|
||||
assert!(write_csv(&Sheet::new("E"), b',', raw).is_empty());
|
||||
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"));
|
||||
|
|
@ -142,3 +142,51 @@ fn hostile_csv() {
|
|||
let wb = read_csv(format!("{wide}x").as_bytes(), &CsvOptions::default()).unwrap();
|
||||
assert!(wb.sheet(0).unwrap().cells.is_empty(), "fields past column XFD are dropped");
|
||||
}
|
||||
|
||||
/// A sheet with `a` in A1 and `b` in `corner`.
|
||||
fn two_cells(corner: (u32, u32)) -> Sheet {
|
||||
let mut s = Sheet::new("S");
|
||||
s.set_value(CellRef::new(0, 0), Value::text("a"));
|
||||
s.set_value(CellRef::new(corner.0, corner.1), Value::text("b"));
|
||||
s
|
||||
}
|
||||
|
||||
fn text_of(sh: &Sheet) -> impl FnMut(CellRef) -> String + '_ {
|
||||
|c| match sh.value(c) {
|
||||
Value::Text(t) => t.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_far_apart_cells_are_refused_before_anything_is_allocated() {
|
||||
// A1 and XFD1048576 span the whole grid: 16 GiB of commas. Refused at once, with the numbers.
|
||||
let s = two_cells((gridcraft_core::MAX_ROWS - 1, gridcraft_core::MAX_COLS - 1));
|
||||
let err = write_csv(&s, b',', text_of(&s)).unwrap_err();
|
||||
assert!(matches!(err, IoError::TooLarge(_)), "{err}");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("1048576 rows by 16384 columns") && msg.contains("512 MiB"), "{msg}");
|
||||
// A single row or column across the grid is refused too when it is over the cap.
|
||||
let wide = two_cells((gridcraft_core::MAX_ROWS - 1, 0));
|
||||
assert!(write_csv_limited(&wide, b',', 1 << 20, text_of(&wide)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_position() {
|
||||
// 3 rows x 3 columns, 14 bytes: accepted at the cap, refused one byte under.
|
||||
let s = two_cells((2, 2));
|
||||
let ok = write_csv_limited(&s, b',', 14, text_of(&s)).unwrap();
|
||||
assert_eq!(String::from_utf8(ok).unwrap(), "a,,\r\n,,\r\n,,b\r\n");
|
||||
assert!(write_csv_limited(&s, b',', 13, text_of(&s)).is_err());
|
||||
// Rows that pass the lower bound but hold long text stop as soon as the cap is passed.
|
||||
let mut long = Sheet::new("L");
|
||||
for r in 0..50u32 {
|
||||
long.set_value(CellRef::new(r, 0), Value::text("x".repeat(100)));
|
||||
}
|
||||
let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err();
|
||||
assert!(err.to_string().contains("stopped at row"), "{err}");
|
||||
assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok());
|
||||
// A tab is one byte, like a comma.
|
||||
let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap();
|
||||
assert_eq!(tabs.len(), 14);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` array formulas fill their range on recalculation) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
|
@ -44,9 +45,9 @@ and repair-free opening remain unverified.
|
|||
|
||||
| Format | Excel | GridCraft read | GridCraft write | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns |
|
||||
| `.prn` formatted text (space-delimited) | R/W | no | no | |
|
||||
| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy |
|
||||
|
|
@ -88,6 +89,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
|
||||
| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |
|
||||
| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls |
|
||||
| 2026-10-12 | minor | `.xls` import: the other cells of an array formula ({=FREQUENCY(...)}, {=TREND(...)}) drop their cached results, as `.xlsx` import does, so the formula fills its range on recalculation instead of showing `#SPILL!` against its own old values; 79 fewer mismatching formulas and 16 fewer round-trip differences over 4,224 real-world files |
|
||||
|
|
|
|||
Loading…
Reference in New Issue