diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8d28c25a5..b3b515c2e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { 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 { 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()})) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 60534a6ed..71060a4c9 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -306,6 +306,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { .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> { + 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('"', """) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b61d85c76..e094332ba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2500,6 +2500,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 { use std::io::{Cursor, Write}; diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 06d0a8693..52cada2c7 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -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) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf567f8bd..ea1cc15db 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; 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 (``) and complex-script (``) 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 |