diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 29bd4b5..b61892f 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -103,6 +103,14 @@ pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 { if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) } } +/// Last serial that is a valid date in a date system: 9999-12-31 (2958465 in the 1900 system, 2957003 in the 1904 system). +pub fn max_serial(sys: DateSystem) -> i64 { + match sys { + DateSystem::D1900 => 2_958_465, + DateSystem::D1904 => 2_958_465 - 1462, + } +} + /// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next /// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900 /// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60. @@ -125,7 +133,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti DateSystem::D1904 => first - UNIX_1904_01_01, }; let serial = first.checked_add(day)?.checked_sub(1)?; - if !(0..=2_958_465).contains(&serial) { + if !(0..=max_serial(sys)).contains(&serial) { return None; } Some(serial as f64) @@ -133,7 +141,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti /// Date/time from a serial. `None` for negative or too-large serials. pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option { - if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) { + if !serial.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&serial) { return None; } let mut day = serial.floor() as i64; @@ -205,6 +213,10 @@ mod tests { assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0)); assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0)); assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0)); + assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 31), Some(2957003.0)); + assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 32), None); + assert!(datetime_from_serial(DateSystem::D1904, 2957003.5).is_some()); + assert!(datetime_from_serial(DateSystem::D1904, 2957004.0).is_none()); assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1)); assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29)); assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1)); diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe59..f02d84b 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -1,7 +1,7 @@ //! Rendering a value with a parsed section. use gridcraft_core::DateSystem; -use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial}; +use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial}; use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit}; use crate::parse::{DatePart, Section, Tok}; @@ -58,7 +58,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { /// Renders a date/time. `None` when the serial is outside the supported range. pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option { - if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) { + if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) { return None; } let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c..f777c6a 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -247,6 +247,11 @@ fn dates_and_times() { assert_eq!(f("m/d/yyyy", 60.0), "2/29/1900"); assert_eq!(f("m/d/yyyy", 0.0), "1/0/1900"); assert_eq!(format_value(&Value::Number(0.0), &NumberFormat::parse("m/d/yyyy"), DateSystem::D1904).text, "1/1/1904"); + let fmt = NumberFormat::parse("m/d/yyyy"); + assert_eq!(format_value(&Value::Number(2957003.5), &fmt, DateSystem::D1904).text, "12/31/9999"); + assert_eq!(format_value(&Value::Number(2957004.0), &fmt, DateSystem::D1904).text, "########"); + assert_eq!(format_value(&Value::Number(2958465.0), &fmt, DateSystem::D1900).text, "12/31/9999"); + assert_eq!(format_value(&Value::Number(2958466.0), &fmt, DateSystem::D1900).text, "########"); assert_eq!(f("m/d/yyyy", -1.0), "########"); assert_eq!(f("m/d/yyyy", 3e6), "########"); assert_eq!(f("[$-409]m/d/yyyy", 46302.0), "10/7/2026"); @@ -339,6 +344,8 @@ fn text_fn() { assert_eq!(text_function(&Value::Empty, "0.00", S), Ok("0.00".into())); assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into())); assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value)); + assert_eq!(text_function(&Value::Number(2957003.0), "m/d/yyyy", DateSystem::D1904), Ok("12/31/9999".into())); + assert_eq!(text_function(&Value::Number(2957004.0), "m/d/yyyy", DateSystem::D1904), Err(CellError::Value)); assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into())); // Booleans go through the text section as TRUE/FALSE (observed Excel results). let cases: &[(bool, &str, &str)] = &[