diff --git a/AGENTS.md b/AGENTS.md index f361043c5..4e6f1ec91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr - **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs. - **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ## Quality gates diff --git a/Cargo.lock b/Cargo.lock index 31028fe10..7f9a0d3e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,6 +704,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.5.2" @@ -2883,7 +2892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5134,6 +5143,7 @@ name = "wordcraft" version = "0.9.0" dependencies = [ "arboard", + "chrono", "clipboard-win", "craft-print", "dispatch2", @@ -5196,6 +5206,7 @@ name = "wordcraft-docbin" version = "0.9.0" dependencies = [ "cfb", + "chrono", "image", "log", "miniz_oxide 0.8.9", @@ -5214,6 +5225,7 @@ dependencies = [ "aes", "base64", "cfb", + "chrono", "getrandom 0.3.4", "hmac", "image", @@ -5235,6 +5247,7 @@ name = "wordcraft-engine" version = "0.9.0" dependencies = [ "cfb", + "chrono", "encoding_rs", "getrandom 0.3.4", "image", @@ -5278,6 +5291,7 @@ version = "0.9.0" dependencies = [ "cfb", "chardetng", + "chrono", "encoding_rs", "image", "js-sys", @@ -5339,6 +5353,7 @@ dependencies = [ name = "wordcraft-pdf" version = "0.9.0" dependencies = [ + "chrono", "craft-pdf", "hayro-cmap", "hayro-interpret", diff --git a/Cargo.toml b/Cargo.toml index 3cba53013..2a7e9aaad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" rayon = "1" unicode-bidi = "0.3" diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 40e0d7a9c..dbad7e93f 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -23,6 +23,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/wordcraft/src/logging.rs b/apps/wordcraft/src/logging.rs index 23a561660..bbcb4054a 100644 --- a/apps/wordcraft/src/logging.rs +++ b/apps/wordcraft/src/logging.rs @@ -94,7 +94,10 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the +/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the +/// output never changes for a value the old formatter accepted and logging never panics. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = since.as_secs(); @@ -103,20 +106,25 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock -/// value can overflow it. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run +/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years +/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the +/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and +/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01 +/// is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -435,8 +443,21 @@ mod tests { // 2000 is a leap year (divisible by 400), 2100 is not. assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. + // Before the epoch (a clock set wrong) clamps to the epoch instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date + // the old formatter gave, rather than falling back to the epoch. FredApps' counterexample: + // UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/docbin/Cargo.toml b/crates/docbin/Cargo.toml index 6aa0a9318..87e9ea1fd 100644 --- a/crates/docbin/Cargo.toml +++ b/crates/docbin/Cargo.toml @@ -17,6 +17,7 @@ miniz_oxide = { workspace = true } sha1 = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/docbin/src/metadata.rs b/crates/docbin/src/metadata.rs index 207591fcc..4b9fcf766 100644 --- a/crates/docbin/src/metadata.rs +++ b/crates/docbin/src/metadata.rs @@ -87,20 +87,8 @@ fn parse(bytes: &[u8]) -> Option { }) } -fn civil(days: i64) -> (i64, u32, u32) { - let z = days + 719_468; - let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097; - let doe = (z - era * 146_097) as u32; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = y + i64::from(m <= 2); - (y, m, d) -} - +/// A `VT_FILETIME` property value as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`), or `None` +/// if it is not a FILETIME, is zero, or predates the 1601 FILETIME epoch. fn filetime_iso(value: &[u8]) -> Option { let kind = u32_at(value, 0)?; if kind != 64 { @@ -114,14 +102,34 @@ fn filetime_iso(value: &[u8]) -> Option { } const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000; let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?; - let unix_secs = unix_100ns / 10_000_000; - let days = (unix_secs / 86_400) as i64; - let secs_of_day = (unix_secs % 86_400) as u32; - let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60); + let unix_secs = i64::try_from(unix_100ns / 10_000_000).ok()?; + let (days, sod) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); + let (h, mi, s) = (sod / 3600, sod / 60 % 60, sod % 60); let (y, m, d) = civil(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A FILETIME can land past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter +/// still produced a date; dropping to `None` there would change the output. The Gregorian calendar +/// repeats every 400 years (146_097 days), so chrono converts the day within one cycle and whole +/// cycles go back into the year (an `i128` so the extremes can't overflow), keeping chrono as the +/// only calendar logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + fn text(value: &[u8], codepage: u16) -> Option { let kind = u32_at(value, 0)?; let count = u32_at(value, 4)? as usize; @@ -151,3 +159,35 @@ fn text(value: &[u8], codepage: u16) -> Option { } } } + +#[cfg(test)] +mod tests { + use super::filetime_iso; + + /// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned + /// to what the old hand-rolled `civil` produced. + fn value(ft: u64) -> Vec { + let mut v = 64u32.to_le_bytes().to_vec(); + v.extend_from_slice(&((ft & 0xffff_ffff) as u32).to_le_bytes()); + v.extend_from_slice(&((ft >> 32) as u32).to_le_bytes()); + v + } + + #[test] + fn filetime_to_iso() { + // 2020-01-01T00:00:00Z and the same day at 13:45:09. + assert_eq!(filetime_iso(&value(132_223_104_000_000_000)).as_deref(), Some("2020-01-01T00:00:00Z")); + assert_eq!(filetime_iso(&value(132_223_599_090_000_000)).as_deref(), Some("2020-01-01T13:45:09Z")); + // Year 10000 keeps the plain zero-padded year (no leading `+` that chrono's `%Y` would add). + assert_eq!(filetime_iso(&value(2_650_467_744_000_000_000)).as_deref(), Some("10000-01-01T00:00:00Z")); + // The largest FILETIME (u64::MAX) lands in year 60056: past year 9999 (so the plain year + // matters) and the old `civil` converted it without ever dropping to the epoch. chrono's + // own range (year 262143) isn't reachable here — a u64 FILETIME caps at unix seconds + // ~1.83e12 — but the 400-year cycle keeps the result exact regardless. + assert_eq!(filetime_iso(&value(u64::MAX)).as_deref(), Some("60056-05-28T05:36:10Z")); + // Zero, a non-FILETIME kind and times before the 1601 epoch are rejected, not a panic. + assert_eq!(filetime_iso(&value(0)), None); + assert_eq!(filetime_iso(&[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(filetime_iso(&value(1)), None); + } +} diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml index 8e599b2a2..4dd1d510c 100644 --- a/crates/docx/Cargo.toml +++ b/crates/docx/Cargo.toml @@ -15,6 +15,7 @@ quick-xml = { workspace = true } zip = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } unicode-bidi = { workspace = true } cfb = { workspace = true } aes = { workspace = true } diff --git a/crates/docx/src/read/chart.rs b/crates/docx/src/read/chart.rs index 82ed31fbd..48cf3350b 100644 --- a/crates/docx/src/read/chart.rs +++ b/crates/docx/src/read/chart.rs @@ -803,18 +803,27 @@ fn excel_stamp(serial: f64, date1904: bool) -> Option { Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs }) } -/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (Howard Hinnant's -/// civil-from-days algorithm). +/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial +/// day number). Serial `n` is `n - 25_569` days from 1970-01-01. +/// +/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats +/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar +/// math: chrono converts the day within its cycle and whole cycles go back into the year (an +/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n` +/// is CE day `n - 25_569 + 719_163`. fn civil(n: i64) -> (i64, u32, u32) { - let z = n - 25_569 + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; - (yoe + era * 400 + i64::from(m <= 2), m, d) + use chrono::Datelike; + // CE day of serial `n`, in i128 so no `n` overflows. + let from_ce = i128::from(n) - 25_569 + 719_163; + let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097)); + match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } } const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; @@ -1975,6 +1984,18 @@ mod tests { assert_eq!(ymd(45292.0), Some((2024, 1, 1))); } + #[test] + fn civil_is_exact_beyond_chronos_range() { + // Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`. + assert_eq!(civil(1), (1899, 12, 31)); + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of + // the epoch; values from the pre-chrono proleptic Gregorian formula. + assert_eq!(civil(100_000_000), (275_690, 9, 11)); + assert_eq!(civil(-100_000_000), (-271_891, 4, 18)); + // The extremes must not overflow or panic (i128 intermediates). + let _ = (civil(i64::MAX), civil(i64::MIN)); + } + #[test] fn number_formats_keep_literal_text_and_currency() { assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €"); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 17dac28de..6d6ef61c1 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } regex = { workspace = true } regex-automata = { workspace = true } image = { workspace = true } diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 41250a21c..24b2788cc 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -123,23 +123,33 @@ pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> { } pub fn days_in_month(y: i64, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, + // The length of a month is the day before the first of the next month. Month lengths repeat + // every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and + // always fits chrono, for any `y`. An invalid month keeps the old 31-day default. + let y = y.rem_euclid(400); + let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); + match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) { + (Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(31), _ => 31, } } -/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]). +/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]), exact for any year. +/// +/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year +/// within its cycle and whole cycles are added back as days (an i128 so the extremes can't +/// overflow). 1970-01-01 is `num_days_from_ce` 719_163; the result matches the proleptic +/// Gregorian count the old hand-rolled code gave. pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let mp = i64::from((m + 9) % 12); - let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097; + i64::try_from(days).ok() + }) + .unwrap_or(0) } /// The built-in name of the placeholder style. Its id is localized: German Word writes @@ -750,6 +760,22 @@ mod tests { assert_eq!(days_from_civil(1970, 1, 1), 0); assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1)); } + + #[test] + fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() { + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the + // pre-chrono formula, both directions, both sides of the epoch. + for (y, m, d, days) in + [(262_144i64, 1u32, 1u32, 95_026_602i64), (273_022, 10, 18, 99_000_000), (-262_145, 3, 1, -96_465_964), (-271_821, 4, 21, -99_999_999)] + { + assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}"); + assert_eq!(super::super::civil_from_days(days), (y, m, d), "{days}"); + } + assert_eq!(days_in_month(272_952, 2), 29, "272952 is a leap year"); + assert_eq!(days_in_month(-269_153, 2), 28); + // The extremes must not overflow or panic (i128 intermediates / `rem_euclid`). + let _ = (days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1), days_in_month(i64::MAX, 2), days_in_month(i64::MIN, 2)); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 27e087d8b..5e3d306ad 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -422,9 +422,17 @@ fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result String { - let (y, m, d) = civil_from_days((secs / 86_400) as i64); + use chrono::Datelike; let t = secs % 86_400; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) + let hms = format!("{:02}:{:02}:{:02}", t / 3600, t / 60 % 60, t % 60); + // Within chrono's range, read the date from it; past it, keep the proleptic Gregorian answer + // `civil_from_days` gives rather than falling back to the epoch. + let (y, m, d) = match i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)) { + Some(dt) => (i64::from(dt.year()), dt.month(), dt.day()), + None => civil_from_days((secs / 86_400) as i64), + }; + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{m:02}-{d:02}T{hms}Z") } /// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web. @@ -447,18 +455,25 @@ pub fn now_iso() -> String { iso_from_unix_secs(now_unix()) } -/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm). +/// Days since 1970-01-01 → (year, month, day). +/// +/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by +/// [`iso_from_unix_secs`] for a `u64` seconds count past it), so it must stay exact for every +/// `z`. The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the day +/// within its cycle and whole cycles go back into the year (an i128 so i64::MIN/MAX can't +/// overflow). 1970-01-01 is `num_days_from_ce` 719_163, and 0..146_097 days from CE day 0 always +/// fit chrono. The result matches the proleptic Gregorian count the old hand-rolled code gave. pub fn civil_from_days(z: i64) -> (i64, u32, u32) { - let z = z + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) + use chrono::Datelike; + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + // Unreachable: `rest + 719_163` is 719_163..865_259, always a valid CE day. + None => (1970, 1, 1), + } } /// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's. @@ -486,7 +501,7 @@ pub fn default_lang(s: &Session) -> Option { #[cfg(test)] mod iso_tests { - use super::iso_from_unix_secs; + use super::{civil_from_days, iso_from_unix_secs}; #[test] fn formats_epoch_and_known_timestamps() { @@ -500,4 +515,26 @@ mod iso_tests { assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z"); assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z"); } + + #[test] + fn formats_dates_outside_chronos_range() { + // Year 10000 is still inside chrono's range: a plain zero-padded year, no leading `+`. + assert_eq!(iso_from_unix_secs(253_402_300_800), "10000-01-01T00:00:00Z"); + // Past chrono's range, keep the proleptic Gregorian date instead of the Unix epoch. + assert_eq!(iso_from_unix_secs(10_000_000_000_000), "318857-05-20T17:46:40Z"); + } + + #[test] + fn civil_from_days_is_exact_beyond_chronos_range() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(20_734), (2026, 10, 8)); + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; values from the + // pre-chrono formula, both directions, both sides of the epoch. + assert_eq!(civil_from_days(95_026_602), (262_144, 1, 1)); + assert_eq!(civil_from_days(99_000_000), (273_022, 10, 18)); + assert_eq!(civil_from_days(-96_465_964), (-262_145, 3, 1)); + assert_eq!(civil_from_days(-99_000_000), (-269_083, 3, 17)); + // The extremes must not overflow or panic (i128 intermediates). + let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN)); + } } diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml index 6db6e4658..8237e3cc5 100644 --- a/crates/formats/Cargo.toml +++ b/crates/formats/Cargo.toml @@ -18,6 +18,7 @@ cfb = { workspace = true } encoding_rs = { workspace = true } chardetng = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # The browser clock (`SystemTime::now()` panics on wasm32): ODT change dates. diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 2df9d4519..597a1020e 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -794,16 +794,10 @@ fn join_deleted_marks(blocks: &[FBlock]) -> Option<(Vec, HashMap String { let t = now_unix(); let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); - // Days since 1970-01-01 → civil date (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); + // Reuse the crate's 400-year-cycle converter (chrono within a cycle, cycles carried in the + // year) so a clock past chrono's range keeps the proleptic Gregorian date the old hand-rolled + // code gave, never the epoch. `{y:04}` rather than `%Y`, which writes a `+` before year 10000. + let (y, m, d) = crate::sheet::civil(days); format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 4585d5c48..fed31d7eb 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -757,17 +757,22 @@ fn serial_date(serial: f64, date1904: bool, kind: DateKind) -> Option { } /// Days since 1970-01-01 to a proleptic Gregorian (year, month, day). -fn civil(z: i64) -> (i64, i64, i64) { - let z = z + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - (y, m, d) +/// +/// The caller's serial guard keeps `z` inside chrono's range, but the Gregorian calendar repeats +/// every 400 years (146_097 days), so this stays exact for any `z` without hand-rolled calendar +/// math: chrono converts the day within its cycle and whole cycles go back into the year (an +/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163. +pub(crate) fn civil(z: i64) -> (i64, i64, i64) { + use chrono::Datelike; + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), i64::from(d.month()), i64::from(d.day())) + } + // Unreachable: `rest + 719_163` is always a valid CE day. + None => (1970, 1, 1), + } } /// An ISO 8601 date or date-time (`2024-03-05`, `2024-03-05T13:45:00.000`) as `yyyy-mm-dd`, @@ -1127,4 +1132,17 @@ mod tests { assert_eq!(iso_datetime("2024-3-1").as_deref(), Some("2024-03-01")); assert_eq!(iso_datetime("2024-03-05Z").as_deref(), Some("2024-03-05")); } + + #[test] + fn civil_is_exact_beyond_chronos_range() { + assert_eq!(civil(0), (1970, 1, 1)); + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of + // the epoch; values from the pre-chrono proleptic Gregorian formula. + assert_eq!(civil(95_026_602), (262_144, 1, 1)); + assert_eq!(civil(99_000_000), (273_022, 10, 18)); + assert_eq!(civil(-96_465_964), (-262_145, 3, 1)); + assert_eq!(civil(-99_000_000), (-269_083, 3, 17)); + // The extremes must not overflow or panic (i128 intermediates). + let _ = (civil(i64::MAX), civil(i64::MIN)); + } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index d85446ed9..5a183bc92 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -19,6 +19,7 @@ kurbo = { workspace = true } image = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # The browser clock (`SystemTime::now()` panics on wasm32). diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e1dab4a1e..1d44554bb 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -458,19 +458,26 @@ fn now_unix() -> Option { } /// Unix seconds (UTC) → civil time (proleptic Gregorian). +/// +/// The year is returned unclamped (the caller clamps it to the PDF `DateTime` range 0..=9999); +/// month, day and time of day always come through correctly. Past chrono's ±262143-year range the +/// Gregorian 400-year cycle (146_097 days) keeps the result exact — chrono converts the day within +/// one cycle and whole cycles go back into the year (an `i128` so the extremes can't overflow) — +/// rather than falling back to the epoch, so the date never changes for a value the old +/// hand-rolled converter accepted. 1970-01-01 is `num_days_from_ce` 719_163. fn civil(t: i64) -> Civil { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); - (year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8) + use chrono::Datelike; + let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + let (y, m, d) = match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(date) => { + let y = i128::from(date.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month() as u8, date.day() as u8) + } + None => (1970, 1, 1), // unreachable + }; + (y, m, d, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8) } fn norm(v: f32) -> NormalizedF32 { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e30805250..5be10b2da 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -447,6 +447,12 @@ fn shapes_have_paths() { assert_eq!(parse_iso("2024-02-03T04:05:06Z"), Some((2024, 2, 3, 4, 5, 6))); assert_eq!(parse_iso("garbage"), None); assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); + // Past chrono's ±262143-year range `civil` keeps the proleptic Gregorian date the old + // hand-rolled converter gave (only the caller's 0..=9999 PDF year clamp applies later), rather + // than falling back to the epoch. Values pinned from the old formula. + assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40)); + assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20)); + assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0)); } #[test]