From 9515c29969a6f3530f9b46300b2de3e5ba308444 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:34:18 -0500 Subject: [PATCH 1/3] CSV export: file.exportCsv values:true writes raw values for automation (#2648) The default stays Excel's displayed text, byte for byte. --- crates/engine/src/cmd/file.rs | 8 ++++++-- crates/engine/src/io.rs | 35 ++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 7 ++++--- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9af85b9dd..27e74dc60 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 77467b210..ba44f1f2b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -294,6 +294,41 @@ 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 kind = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).kind(); + let dt = matches!(kind, gridcraft_numfmt::FormatKind::Date | gridcraft_numfmt::FormatKind::Time) + .then(|| gridcraft_core::date::datetime_from_serial(wb.date_system, n)) + .flatten(); + match dt { + Some(d) => iso(&d, kind == gridcraft_numfmt::FormatKind::Time && n < 1.0, n.fract() != 0.0), + 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())) +} + +fn iso(d: &gridcraft_core::date::DateTime, time_only: bool, with_time: bool) -> String { + let date = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day); + let mut time = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second); + if d.milli != 0 { + time.push_str(&format!(".{:03}", d.milli)); + } + match (time_only, with_time) { + (true, _) => time, + (false, true) => format!("{date}T{time}"), + (false, false) => date, + } +} + 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 13fbb9b3a..c42d1484b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2460,6 +2460,44 @@ 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] +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"), + ] { + 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!\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/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 | From 9595fc8ee7006c3edfed5ae955d91b3393325d4a Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:42:23 -0500 Subject: [PATCH 2/3] CSV export values: pick the ISO shape from the format's date and time parts; guard the file test on wasm32 --- crates/engine/src/io.rs | 26 ++++++++++++-------------- crates/engine/src/tests.rs | 10 +++++++++- crates/numfmt/src/lib.rs | 14 +++++++++++++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ba44f1f2b..3b8bacfe4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -301,12 +301,9 @@ 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 kind = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).kind(); - let dt = matches!(kind, gridcraft_numfmt::FormatKind::Date | gridcraft_numfmt::FormatKind::Time) - .then(|| gridcraft_core::date::datetime_from_serial(wb.date_system, n)) - .flatten(); - match dt { - Some(d) => iso(&d, kind == gridcraft_numfmt::FormatKind::Time && n < 1.0, n.fract() != 0.0), + let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(); + 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(), } } @@ -316,16 +313,17 @@ pub fn csv_values_bytes(wb: &Workbook) -> Result> { .map_err(|e| EngineError::Other(e.to_string())) } -fn iso(d: &gridcraft_core::date::DateTime, time_only: bool, with_time: bool) -> String { - let date = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day); - let mut time = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second); +/// `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 { - time.push_str(&format!(".{:03}", d.milli)); + clock.push_str(&format!(".{:03}", d.milli)); } - match (time_only, with_time) { - (true, _) => time, - (false, true) => format!("{date}T{time}"), - (false, false) => date, + match (date, time) { + (true, true) => format!("{day}T{clock}"), + (false, true) => clock, + _ => day, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c42d1484b..c76320592 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2463,6 +2463,7 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() { /// `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 [ @@ -2474,6 +2475,10 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { ("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"), ] { s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap(); @@ -2494,7 +2499,10 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { 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!\r\n"); + 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\r\n" + ); std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 06d0a8693..d5c4ef41b 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 its first section 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) -> Option<(bool, bool)> { + let s = self.num_sections().first().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) } From f4f48f3b23efb56cc5e3d4e5fd726d73bc1b70d7 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:51:42 -0500 Subject: [PATCH 3/3] CSV export values: choose the date or time shape from the format section that formats the value --- crates/engine/src/io.rs | 2 +- crates/engine/src/tests.rs | 3 ++- crates/numfmt/src/lib.rs | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 3b8bacfe4..ce5b7a871 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -301,7 +301,7 @@ 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(); + 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(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c76320592..c97f033d1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2479,6 +2479,7 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { ("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(); @@ -2501,7 +2502,7 @@ fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() { 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\r\n" + "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(); } diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index d5c4ef41b..52cada2c7 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -193,11 +193,11 @@ impl NumberFormat { self.num_sections().iter().any(|s| s.kind == SecKind::Date) } - /// For a date or time format, whether its first section shows calendar parts (year, month, + /// 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) -> Option<(bool, bool)> { - let s = self.num_sections().first().filter(|s| s.kind == SecKind::Date)?; + 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;