Skip to main content

excelize_rs/
date.rs

1//! Date and time conversion helpers.
2//!
3//! Mirrors the logic in Go `date.go`, converting between Excel serial numbers
4//! and `chrono` naive date/time types while accounting for the 1900/1904 date
5//! system and the Excel 1900 leap-year bug.
6
7use chrono::{NaiveDate, NaiveDateTime, NaiveTime, SubsecRound, Timelike};
8
9use crate::errors::{ErrInvalidDate, Result};
10
11const NANOS_IN_A_DAY: f64 = 24.0 * 60.0 * 60.0 * 1_000_000_000.0;
12const ROUND_EPSILON: f64 = 1e-9;
13
14/// Forward-conversion epoch for the 1900 date system (1899-12-31).
15///
16/// Excel's 1900 serial system counts from 1899-12-31 as day 0.
17pub const EXCEL_EPOCH_1900: fn() -> NaiveDateTime = || {
18    NaiveDate::from_ymd_opt(1899, 12, 31)
19        .unwrap()
20        .and_hms_opt(0, 0, 0)
21        .unwrap()
22};
23
24/// Reverse-conversion epoch for the 1900 date system (1899-12-30).
25///
26/// Used after the 1900 leap-year bug so that serial arithmetic lines up
27/// with Excel's internal day count.
28pub const EXCEL_REVERSE_EPOCH_1900: fn() -> NaiveDateTime = || {
29    NaiveDate::from_ymd_opt(1899, 12, 30)
30        .unwrap()
31        .and_hms_opt(0, 0, 0)
32        .unwrap()
33};
34
35/// Excel epoch for the 1904 date system (1904-01-01).
36pub const EXCEL_EPOCH_1904: fn() -> NaiveDateTime = || {
37    NaiveDate::from_ymd_opt(1904, 1, 1)
38        .unwrap()
39        .and_hms_opt(0, 0, 0)
40        .unwrap()
41};
42
43/// Earliest representable date in the 1900 date system.
44pub const EXCEL_MIN_TIME_1900: fn() -> NaiveDateTime = || {
45    NaiveDate::from_ymd_opt(1899, 12, 31)
46        .unwrap()
47        .and_hms_opt(0, 0, 0)
48        .unwrap()
49};
50
51/// Start of the period affected by the Excel 1900 leap-year bug (1900-03-01).
52pub const EXCEL_BUGGY_PERIOD_START: fn() -> NaiveDateTime = || {
53    NaiveDate::from_ymd_opt(1900, 3, 1)
54        .unwrap()
55        .and_hms_opt(0, 0, 0)
56        .unwrap()
57};
58
59const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
60
61/// Determine whether a year is a leap year.
62pub fn is_leap_year(y: i32) -> bool {
63    (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
64}
65
66/// Return the number of days in the given month.
67pub fn get_days_in_month(y: i32, m: u32) -> u32 {
68    if m == 2 && is_leap_year(y) {
69        29
70    } else {
71        DAYS_IN_MONTH[m as usize - 1]
72    }
73}
74
75/// Validate whether the given year, month and day form a legal date.
76pub fn validate_date(y: i32, m: u32, d: u32) -> bool {
77    if m < 1 || m > 12 || d < 1 {
78        return false;
79    }
80    d <= get_days_in_month(y, m)
81}
82
83/// Convert a 2-digit year into a 4-digit year using Excel's 30/1900 rule.
84pub fn format_year(y: i32) -> i32 {
85    if y < 1900 {
86        if y < 30 { y + 2000 } else { y + 1900 }
87    } else {
88        y
89    }
90}
91
92/// Convert a `NaiveDateTime` to an Excel serial number.
93///
94/// Applies the 1900/1904 date system and the 1900 leap-year bug compatibility.
95pub fn datetime_to_excel_serial(dt: NaiveDateTime, date1904: bool) -> f64 {
96    let min_date = if date1904 {
97        EXCEL_EPOCH_1904()
98    } else {
99        EXCEL_MIN_TIME_1900()
100    };
101    if dt < min_date {
102        return 0.0;
103    }
104
105    let base = if date1904 {
106        EXCEL_EPOCH_1904()
107    } else {
108        EXCEL_EPOCH_1900()
109    };
110    let duration = dt.signed_duration_since(base);
111    let mut days = duration.num_seconds() as f64 / 86400.0;
112
113    // Excel treats 1900 as a leap year for compatibility with Lotus 1-2-3.
114    if !date1904 && dt >= EXCEL_BUGGY_PERIOD_START() {
115        days += 1.0;
116    }
117    days
118}
119
120/// Convert a `NaiveDate` to an Excel serial number.
121pub fn date_to_excel_serial(d: NaiveDate, date1904: bool) -> f64 {
122    datetime_to_excel_serial(d.and_hms_opt(0, 0, 0).unwrap_or_default(), date1904)
123}
124
125/// Convert a `NaiveTime` to the fractional part of an Excel day.
126pub fn time_to_excel_serial(t: NaiveTime) -> f64 {
127    let seconds = t.num_seconds_from_midnight() as f64;
128    let nano = t.nanosecond() as f64 / 1_000_000_000.0;
129    (seconds + nano) / 86400.0
130}
131
132/// Convert an Excel serial number to a `NaiveDateTime`.
133///
134/// Negative serial numbers are invalid and return an error.
135pub fn excel_serial_to_datetime(excel_time: f64, date1904: bool) -> Result<NaiveDateTime> {
136    if excel_time < 0.0 {
137        return Err(Box::new(ErrInvalidDate));
138    }
139    Ok(time_from_excel_time(excel_time, date1904))
140}
141
142fn time_from_excel_time(excel_time: f64, date1904: bool) -> NaiveDateTime {
143    let whole_days_part = excel_time as i64;
144
145    // Excel uses Julian dates prior to March 1st 1900 and Gregorian thereafter.
146    if whole_days_part <= 61 {
147        const OFFSET1900: f64 = 15018.0;
148        const OFFSET1904: f64 = 16480.0;
149        const MJD0: f64 = 2400000.5;
150        let offset = if date1904 { OFFSET1904 } else { OFFSET1900 };
151        return julian_date_to_gregorian_time(MJD0, excel_time + offset);
152    }
153
154    let float_part = excel_time - (whole_days_part as f64) + ROUND_EPSILON;
155    let base = if date1904 {
156        EXCEL_EPOCH_1904()
157    } else {
158        EXCEL_REVERSE_EPOCH_1900()
159    };
160
161    let duration = chrono::Duration::nanoseconds((NANOS_IN_A_DAY * float_part) as i64);
162    let mut date = base
163        .checked_add_signed(chrono::Duration::days(whole_days_part))
164        .unwrap_or(base)
165        .checked_add_signed(duration)
166        .unwrap_or(base);
167
168    // Round or truncate to the nearest second to avoid nanosecond noise.
169    if date.nanosecond() / 1_000_000 > 500 {
170        date = date.round_subsecs(0);
171    } else {
172        date = date.trunc_subsecs(0);
173    }
174    date
175}
176
177fn shift_julian_to_noon(julian_days: f64, julian_fraction: f64) -> (f64, f64) {
178    match julian_fraction {
179        f if (-0.5..0.5).contains(&f) => (julian_days, julian_fraction + 0.5),
180        f if f >= 0.5 => (julian_days + 1.0, julian_fraction - 0.5),
181        f if f <= -0.5 => (julian_days - 1.0, julian_fraction + 1.5),
182        _ => (julian_days, julian_fraction),
183    }
184}
185
186fn fraction_of_a_day(fraction: f64) -> (i32, i32, i32, i32) {
187    const C1US: i64 = 1_000;
188    const C1S: i64 = 1_000_000_000;
189    const C1DAY: i64 = 24 * 60 * 60 * C1S;
190
191    let mut frac = (C1DAY as f64 * fraction + C1US as f64 / 2.0) as i64;
192    let nanoseconds = ((frac % C1S) / C1US) as i32 * C1US as i32;
193    frac /= C1S;
194    let seconds = (frac % 60) as i32;
195    frac /= 60;
196    let minutes = (frac % 60) as i32;
197    let hours = (frac / 60) as i32;
198    (hours, minutes, seconds, nanoseconds)
199}
200
201fn julian_date_to_gregorian_time(part1: f64, part2: f64) -> NaiveDateTime {
202    let part1_i = part1.trunc();
203    let part1_f = part1.fract();
204    let part2_i = part2.trunc();
205    let part2_f = part2.fract();
206
207    let mut julian_days = part1_i + part2_i;
208    let mut julian_fraction = part1_f + part2_f;
209    (julian_days, julian_fraction) = shift_julian_to_noon(julian_days, julian_fraction);
210
211    let (day, month, year) = fliegel_van_flandern(julian_days as i32);
212    let (hours, minutes, seconds, nanoseconds) = fraction_of_a_day(julian_fraction);
213
214    NaiveDate::from_ymd_opt(year, month as u32, day as u32)
215        .unwrap_or_else(|| NaiveDate::from_ymd_opt(1899, 12, 30).unwrap())
216        .and_hms_nano_opt(
217            hours as u32,
218            minutes as u32,
219            seconds as u32,
220            nanoseconds as u32,
221        )
222        .unwrap_or_default()
223}
224
225fn fliegel_van_flandern(jd: i32) -> (i32, i32, i32) {
226    let mut l = jd + 68569;
227    let n = (4 * l) / 146097;
228    l = l - (146097 * n + 3) / 4;
229    let i = (4000 * (l + 1)) / 1461001;
230    l = l - (1461 * i) / 4 + 31;
231    let j = (80 * l) / 2447;
232    let d = l - (2447 * j) / 80;
233    l = j / 11;
234    let m = j + 2 - (12 * l);
235    let y = 100 * (n - 49) + i + l;
236    (d, m, y)
237}
238
239/// Convenience helper for date/time conversions used elsewhere in the crate.
240pub struct Date;
241
242impl Date {
243    /// Convert a `NaiveDateTime` to an Excel serial number.
244    pub fn datetime_to_serial(dt: NaiveDateTime, date1904: bool) -> f64 {
245        datetime_to_excel_serial(dt, date1904)
246    }
247
248    /// Convert a `NaiveDate` to an Excel serial number.
249    pub fn date_to_serial(d: NaiveDate, date1904: bool) -> f64 {
250        date_to_excel_serial(d, date1904)
251    }
252
253    /// Convert a `NaiveTime` to the fractional part of an Excel day.
254    pub fn time_to_serial(t: NaiveTime) -> f64 {
255        time_to_excel_serial(t)
256    }
257
258    /// Convert an Excel serial number to a `NaiveDateTime`.
259    pub fn serial_to_datetime(serial: f64, date1904: bool) -> Result<NaiveDateTime> {
260        excel_serial_to_datetime(serial, date1904)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_datetime_to_excel_serial_1900() {
270        let dt = NaiveDate::from_ymd_opt(2024, 7, 13)
271            .unwrap()
272            .and_hms_opt(12, 0, 0)
273            .unwrap();
274        // 2024-07-13 12:00 is serial 45486.5 in the 1900 system.
275        assert!((datetime_to_excel_serial(dt, false) - 45486.5).abs() < 1e-9);
276    }
277
278    #[test]
279    fn test_datetime_to_excel_serial_1904() {
280        let dt = NaiveDate::from_ymd_opt(2024, 7, 13)
281            .unwrap()
282            .and_hms_opt(0, 0, 0)
283            .unwrap();
284        // 1904 system is 1462 days behind the 1900 system.
285        assert!((datetime_to_excel_serial(dt, true) - (45486.0 - 1462.0)).abs() < 1e-9);
286    }
287
288    #[test]
289    fn test_1900_leap_year_bug() {
290        // 1900-03-01 serial in 1900 system should be 61 (Excel counts 2/29/1900).
291        let dt = NaiveDate::from_ymd_opt(1900, 3, 1)
292            .unwrap()
293            .and_hms_opt(0, 0, 0)
294            .unwrap();
295        assert_eq!(datetime_to_excel_serial(dt, false), 61.0);
296    }
297
298    #[test]
299    fn test_excel_serial_to_datetime_1900() {
300        let dt = excel_serial_to_datetime(45486.5, false).unwrap();
301        assert_eq!(dt.date(), NaiveDate::from_ymd_opt(2024, 7, 13).unwrap());
302        assert_eq!(dt.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
303    }
304
305    #[test]
306    fn test_excel_serial_to_datetime_1904() {
307        let dt = excel_serial_to_datetime(44024.5, true).unwrap();
308        assert_eq!(dt.date(), NaiveDate::from_ymd_opt(2024, 7, 13).unwrap());
309        assert_eq!(dt.time(), NaiveTime::from_hms_opt(12, 0, 0).unwrap());
310    }
311
312    #[test]
313    fn test_time_to_excel_serial() {
314        let t = NaiveTime::from_hms_opt(12, 0, 0).unwrap();
315        assert!((time_to_excel_serial(t) - 0.5).abs() < 1e-9);
316    }
317
318    #[test]
319    fn test_negative_serial_is_error() {
320        assert!(excel_serial_to_datetime(-1.0, false).is_err());
321    }
322
323    #[test]
324    fn test_is_leap_year() {
325        assert!(is_leap_year(2024));
326        assert!(!is_leap_year(2023));
327        assert!(!is_leap_year(1900));
328        assert!(is_leap_year(2000));
329    }
330
331    #[test]
332    fn test_get_days_in_month() {
333        assert_eq!(get_days_in_month(2024, 2), 29);
334        assert_eq!(get_days_in_month(2023, 2), 28);
335        assert_eq!(get_days_in_month(2024, 1), 31);
336        assert_eq!(get_days_in_month(2024, 4), 30);
337    }
338}