Browse / github.com/storytold/gridcraft / patch
CSV export: opt-in values: true writes raw cell values (#2648)
CSV export: opt-in values: true writes raw cell values (#2648) What was wrong: automation callers (MCP, CLI) of file.exportCsv only get displayed text (currency strings, rounded numbers), which loses data. Excel's own Save As CSV writes displayed text too, so that default is Excel parity and is unchanged. Fix: file.exportCsv takes an optional values param. values: true writes raw values: numbers at full precision, dates and times as ISO 8601, TRUE/FALSE, error text, text as stored. Without it, or with values: false, the output is the same bytes as before. The param is in the command's params doc (so MCP and the catalog show it) and in docs/file-format-pa
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #2666, open)
- Base
f6b0cec67e328f2f6352768b5cb2feb7e562c5db- Size
- +103 −6 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
94938defce0aac1f690b9c15
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/file.rs crates/engine/src/io.rs crates/engine/src/tests.rs crates/numfmt/src/lib.rs docs/file-format-parity.md
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),- cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),+ cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), cmd!(@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> { wb.set_active_sheet(target_sheet(s, p)?); } let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };- let bytes = crate::io::save_bytes(&wb, &target)?;+ let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {+ crate::io::csv_values_bytes(&wb)?+ } else {+ crate::io::save_bytes(&wb, &target)?+ }; crate::io::write_file(&target, &bytes)?; Ok(json!({"path": target, "bytes": bytes.len()})) }@@ -294,6 +294,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> { .map_err(|e| EngineError::Other(e.to_string())) } +/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and+/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV+/// writes displayed text instead ([`save_bytes`]) and stays the default.+pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {+ let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };+ gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {+ gridcraft_core::Value::Number(n) if n.is_finite() => {+ let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);+ match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {+ Some(((date, time), d)) => iso(&d, date, time),+ None => n.to_string(),+ }+ }+ gridcraft_core::Value::Text(t) => t.to_string(),+ v => crate::display::value_text_as(wb, &v, "General"),+ })+ .map_err(|e| EngineError::Other(e.to_string()))+}++/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.+fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String {+ let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);+ let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);+ if d.milli != 0 {+ clock.push_str(&format!(".{:03}", d.milli));+ }+ match (date, time) {+ (true, true) => format!("{day}T{clock}"),+ (false, true) => clock,+ _ => day,+ }+}+ pub(crate) fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) }@@ -2460,6 +2460,53 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { std::fs::remove_dir_all(dir).unwrap(); } +/// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the+/// raw values for automation: full precision, ISO dates and times.+#[test]+#[cfg(not(target_arch = "wasm32"))]+fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() {+ let mut s = s();+ for (cell, input, code) in [+ ("A1", "1234.5", "$#,##0.00"),+ ("B1", "0.123456789", "0.00"),+ ("C1", "46023", "d-mmm-yy"),+ ("D1", "46023.5", "yyyy-mm-dd hh:mm"),+ ("E1", "0.75", "h:mm"),+ ("F1", "TRUE", "General"),+ ("G1", "a,b", "General"),+ ("H1", "=1/0", "General"),+ ("I1", "1", "h:mm"),+ ("J1", "1.5", "h:mm"),+ ("K1", "46023", "yyyy-mm-dd hh:mm"),+ ("L1", "1.5", "[h]:mm"),+ ("M1", "2.5", "[>=3]yyyy-mm-dd;[<3]hh:mm"),+ ] {+ s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();+ s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap();+ }+ #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name+ let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();+ let dir = std::env::temp_dir().join(format!("gridcraft-csv-values-{}-{nonce}", std::process::id()));+ std::fs::create_dir(&dir).unwrap();+ let displayed = dir.join("displayed.csv");+ s.execute("file.exportCsv", json!({"path": displayed})).unwrap();+ s.execute("file.exportCsv", json!({"path": dir.join("off.csv"), "values": false})).unwrap();+ let raw = dir.join("raw.csv");+ s.execute("file.exportCsv", json!({"path": raw, "values": true})).unwrap();+ let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();+ let default = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();+ // The default export is byte for byte what Save As CSV writes, and `values: false` is the default.+ assert_eq!(std::fs::read(&displayed).unwrap(), default);+ assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default);+ let default = String::from_utf8(default).unwrap();+ assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}");+ assert_eq!(+ std::fs::read_to_string(&raw).unwrap(),+ "1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5,12:00:00\r\n"+ );+ std::fs::remove_dir_all(dir).unwrap();+}+ /// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target). fn linked_workbook(link: &str) -> Vec<u8> { use std::io::{Cursor, Write};@@ -15,7 +15,7 @@ use gridcraft_core::{CellError, DateSystem, Value}; pub use builtin::{builtin_format, builtin_id}; pub use decimal::format_general_fit; -use parse::{SecKind, Section, Tok};+use parse::{DatePart, SecKind, Section, Tok}; use render::Out; /// Width (in characters) of General output in a cell.@@ -193,6 +193,18 @@ impl NumberFormat { self.num_sections().iter().any(|s| s.kind == SecKind::Date) } + /// For a date or time format, whether the section that formats `value` shows calendar parts (year, month,+ /// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for+ /// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment.+ pub fn date_shape(&self, value: f64) -> Option<(bool, bool)> {+ let s = self.select(value).map(|(s, _)| s).filter(|s| s.kind == SecKind::Date)?;+ let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None });+ if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) {+ return None;+ }+ Some((parts().any(DatePart::is_calendar), parts().any(|d| !d.is_calendar())))+ }+ pub fn is_percent(&self) -> bool { self.num_sections().iter().any(|s| s.kind == SecKind::Number && s.percent > 0) }@@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**@@ -66,9 +66,9 @@ values) is still dropped. | 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, likely Windows-1256 Arabic detection, otherwise 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). |+| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise 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, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. 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`; likely Windows-1256 Arabic detection for invalid UTF-8) | 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, likely Windows-1256 Arabic detection, otherwise 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). |+| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise 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 `#####`); `file.exportCsv {values: true}` writes raw values instead. 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`; likely Windows-1256 Arabic detection for invalid UTF-8) | 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 |@@ -115,6 +115,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters | | 2026-10-20 | trivial | A workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) | | 2026-10-19 | minor | XLSX font charset and family values are preserved on save, retaining the character set and family used for substitute font selection |+| 2026-10-26 | trivial | `file.exportCsv {values: true}` writes raw cell values (full-precision numbers, ISO 8601 dates and times) for automation; the default stays Excel's displayed text (#2648) | | 2026-10-19 | minor | CSV and text import decodes likely Windows-1256 Arabic; other invalid UTF-8 retains the Windows-1252 fallback | | 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets | | 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over |