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) }