diff --git a/CLAUDE.md b/CLAUDE.md index 12779b1e3..34f8ab85c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - No `unsafe` (`unsafe_code = "forbid"`). - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. +- **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). - **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. - Tests mirror the zooms: property → unit → crate integration → end-to-end. - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path. diff --git a/Cargo.lock b/Cargo.lock index 449b1f82b..a439c0b80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3940,6 +3940,7 @@ dependencies = [ name = "pdfcraft" version = "0.11.0" dependencies = [ + "chrono", "craft-quit", "eframe", "egui", @@ -4048,6 +4049,7 @@ dependencies = [ name = "pdfcraft-cos" version = "0.11.0" dependencies = [ + "chrono", "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", @@ -4190,6 +4192,7 @@ name = "pdfcraft-js" version = "0.11.0" dependencies = [ "boa_engine", + "chrono", ] [[package]] @@ -4327,6 +4330,7 @@ dependencies = [ "bp256", "bp384", "cbc", + "chrono", "crypto-bigint", "des", "ecdsa", @@ -4362,6 +4366,7 @@ dependencies = [ "arboard", "base64 0.22.1", "bytemuck", + "chrono", "craft-quit", "craft-ui", "eframe", @@ -8035,6 +8040,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "lopdf", "pdfcraft-cos", "pdfcraft-oracle", diff --git a/Cargo.toml b/Cargo.toml index 18afce999..0106433df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" kurbo = "0.13" +# 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"] } # testing proptest = "1" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 3ce1c96ef..6943ad6e4 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -23,6 +23,8 @@ egui = { workspace = true } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } +# The log timestamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index 71ed1d2d2..bef27de16 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -90,7 +90,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(); @@ -99,20 +102,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). `days` is at most -/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 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 = yoe + era * 400 + 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 { @@ -337,6 +345,18 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z"); // Before the epoch (a clock set wrong) clamps 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. + // 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/cos/Cargo.toml b/crates/cos/Cargo.toml index 9ef9126ca..a2fb3f4c4 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -12,6 +12,8 @@ workspace = true pdfcraft-crypt = { workspace = true } pdfcraft-filters = { workspace = true } thiserror = { workspace = true } +# PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [dev-dependencies] pdfcraft-oracle = { workspace = true } diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 331f97d44..786dcd968 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -578,22 +578,33 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { - let days = unix_secs.div_euclid(86_400); - let secs = unix_secs.rem_euclid(86_400); - // Civil-from-days (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 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; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; + let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (and a PDF date is a + // bare four-plus-digit year, never signed). No year clamp — the old code wrote `y` as-is. format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range, +/// which the old hand-rolled converter formatted directly. 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_from_days(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 + } +} + #[cfg(test)] mod tests { use proptest::prelude::*; @@ -656,6 +667,26 @@ mod tests { } } + #[test] + fn pdf_dates_are_utc_and_byte_identical() { + // `D:YYYYMMDDHHmmSSZ` (ISO 32000-2 §7.9.4), the same strings the hand-rolled code wrote. + assert_eq!(pdf_date(0), "D:19700101000000Z"); + assert_eq!(pdf_date(1_790_000_000), "D:20260921141320Z"); + // Leap days, including the 400-year rule, and the last second of a year. + assert_eq!(pdf_date(1_709_164_800), "D:20240229000000Z"); + assert_eq!(pdf_date(951_782_400), "D:20000229000000Z"); + assert_eq!(pdf_date(1_767_225_599), "D:20251231235959Z"); + // Before the epoch (seconds into a day are counted forward from midnight UTC). + assert_eq!(pdf_date(-1), "D:19691231235959Z"); + // Years past 9999 keep the plain zero-padded year (no `+`), as the hand-rolled code did. + assert_eq!(pdf_date(253_402_300_800), "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (which had no year clamp — a negative year keeps + // its sign, exactly as before). + assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z"); + assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z"); + } + #[test] fn reals_keep_full_precision_without_exponents() { for r in [0.1, -0.000_000_123_456_789, 1e-12, 123_456.789_012_345, 1.0 / 3.0, -2.5e7 + 0.25] { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff5e7611d..bd92d3dca 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -48,5 +48,6 @@ icu_properties = "2.3" unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# Already in the graph through lopdf; used only for the local UTC offset ("today"). -chrono = { version = "0.4", default-features = false, features = ["clock"] } +# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace +# entry is std-only; this target adds `clock` for `chrono::Local::now`. +chrono = { workspace = true, features = ["clock"] } diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index ee09b8a8e..c0df85057 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -12,6 +12,9 @@ workspace = true [dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] } +# FormCalc Date/Num2Date calendar conversion goes through chrono (no hand-rolled calendar code); +# std-only, so it stays wasm-safe. +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index dc32d457b..1ab7ca24e 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1838,30 +1838,50 @@ impl Interp<'_> { // ── built-in functions ────────────────────────────────────────────────────────────────────── +/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so +/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). +const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; + +/// Days in one 400-year Gregorian cycle; the calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + /// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +/// +/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` +/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date +/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian +/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; +/// anything else reads as 0. fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - // Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1. - 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; - let unix = era * 146_097 + doe - 719_468; - unix + 25_568 + use chrono::Datelike; + 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(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); + i64::try_from(days).ok() + }) + .unwrap_or(0) } +/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any +/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. fn civil_from_days(n: i64) -> (i64, u32, u32) { - let z = n - 25_568 + 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; + // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. + let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + match date { + 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), + } } /// Day numbers (and years) beyond this are refused rather than computed. @@ -1925,10 +1945,15 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { /// Number of days in month `m` (1-12) of year `y`, leap years included. fn days_in(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 the month from chrono (the first of the next month, less the first of this + // one). An invalid month keeps the old 31-day default. + // Month lengths repeat every 400 years, so the year within its cycle is enough (and always + // in chrono's range, whatever `y` is). + 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)); + let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; + match (first(y, m), next) { + (Some(a), Some(b)) => (b - a).num_days() as u32, _ => 31, } } @@ -2591,6 +2616,23 @@ mod tests { assert_eq!(days_from_civil(1900, 1, 1), 1); assert_eq!(days_from_civil(1970, 1, 1), 25_568); assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29)); + // Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono + // formula, both directions, both sides of the epoch. + for (n, date) in [ + (99_000_000, (272_952, 10, 16)), + (99_999_999, (275_690, 9, 11)), + (-99_000_000, (-269_153, 3, 16)), + (-99_999_999, (-271_891, 4, 20)), + (72_354_905, (200_000, 12, 31)), + (-73_742_460, (-200_000, 1, 1)), + ] { + assert_eq!(civil_from_days(n), date, "{n}"); + assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}"); + } + assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year"); + assert_eq!(days_in(-269_153, 2), 28); + assert_eq!(days_in(i64::MAX, 12), 31); + let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1)); assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3))); assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3))); assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3))); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e5..f6bbdb352 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -651,6 +651,17 @@ mod formcalc { assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x"); } + /// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the + /// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format. + #[test] + fn date_numbers_beyond_chronos_range_keep_their_dates() { + assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16"); + assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16"); + assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11"); + assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16"); + assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20"); + } + #[test] fn str_and_format_round_exact_ties_away_from_zero() { assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 73210cff4..d58811d55 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-fonts = { workspace = true } thiserror.workspace = true +# ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } base64 = "0.22" sha1 = "0.11" sha2 = "0.11" diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index 5c816eae9..6248c1e9e 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -285,6 +285,9 @@ fn contents_len(input: &[u8], form: Form, depth: usize) -> Result i64 { - let (y, m) = if self.month <= 2 { (self.year as i64 - 1, self.month as i64 + 9) } else { (self.year as i64, self.month as i64 - 3) }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * m + 2) / 5 + self.day as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; + use chrono::Datelike; + // The calendar date goes through chrono; the time of day is a plain second count, as + // before. A date chrono cannot represent (an out-of-range field) reads as the epoch day, + // which no well-formed UTCTime/GeneralizedTime produces. + let days = i32::try_from(self.year) + .ok() + .and_then(|y| chrono::NaiveDate::from_ymd_opt(y, self.month, self.day)) + .map_or(0, |d| i64::from(d.num_days_from_ce()) - UNIX_EPOCH_DAYS_FROM_CE); days * 86_400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 } + /// The UTC time at `t` seconds since 1970-01-01. + /// + /// The calendar date goes through chrono's 400-year Gregorian cycle (146_097 days) so a `t` + /// past `NaiveDate`'s ±262143-year range keeps the proleptic Gregorian date the old hand-rolled + /// converter produced instead of falling back to the epoch. The `year` field is a `u32`, so the + /// year is cast exactly as the old code did (`as u32`, wrapping) — no well-formed signing time + /// is anywhere near that range, and this preserves byte-for-byte the old output everywhere. pub fn from_unix(t: i64) -> Time { - 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) as u32; - let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let year = (yoe + era * 400 + if month <= 2 { 1 } else { 0 }) as u32; + 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 (year, month, 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; + // Match the old `as u32` cast: saturate i128→i64 first (the old year was i64), then wrap into u32. + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }) as u32, d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + }; Time { year, month, day, hour: (secs / 3600) as u32, minute: (secs % 3600 / 60) as u32, second: (secs % 60) as u32 } } @@ -591,5 +604,13 @@ mod tests { assert_eq!(Tlv::parse_all(&far.encode()).unwrap().tag, tag::GENERALIZED_TIME); assert_eq!(Time::from_pdf("D:20261002113005+02'00'"), Some(Time { year: 2026, month: 10, day: 2, hour: 9, minute: 30, second: 5 })); assert_eq!(Time::from_unix(0), Time { year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0 }); + // Past chrono's ±262143-year range `from_unix` keeps the proleptic Gregorian date the old + // hand-rolled converter gave, not the epoch. No real signing time is near this; the `year` + // field is a `u32`, so the year wraps exactly as the old `as u32` cast did (values pinned + // from the old formula). This also covers both i64 extremes so neither div_euclid panics. + assert_eq!(Time::from_unix(10_000_000_000_000), Time { year: 318857, month: 5, day: 20, hour: 17, minute: 46, second: 40 }); + assert_eq!(Time::from_unix(-10_000_000_000_000), Time { year: 4_294_652_378, month: 8, day: 13, hour: 6, minute: 13, second: 20 }); + assert_eq!(Time::from_unix(i64::MAX), Time { year: 219_250_468, month: 12, day: 4, hour: 15, minute: 30, second: 7 }); + assert_eq!(Time::from_unix(i64::MIN), Time { year: 4_075_720_767, month: 1, day: 27, hour: 8, minute: 29, second: 52 }); } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 42e9474f0..8b091945a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,9 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +# UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono +# (no hand-rolled calendar code); std-only, so it stays wasm-safe. +chrono = { workspace = true } # Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu. bytemuck = { version = "1", features = ["extern_crate_alloc"] } diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 6704ce227..62d916315 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -1861,24 +1861,37 @@ pub(crate) fn ago(time: SystemTime) -> String { } } -/// `time` as "2026-10-08 14:05 UTC". +/// `time` as "2026-10-08 14:05 UTC". A clock before 1970 reads as the epoch; past chrono's year +/// 262143 range the proleptic Gregorian date is kept via the 400-year cycle, not the epoch. fn utc_stamp(time: SystemTime) -> String { let secs = time.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let days = i64::try_from(secs / 86_400).unwrap_or(0); - let rem = secs % 86_400; - // Civil date from days since 1970-01-01 (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); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", rem / 3600, rem % 3600 / 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A pre-1970 time already clamps to the epoch (`unwrap_or(0)`), so `days` is an unsigned count +/// that can run past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter still +/// produced a date. 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 +/// result exactly. 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 + } +} + impl PdfCraftApp { /// Show the Combine files tab (File ▸ Combine files…), opening it if needed. pub fn open_combine_tab(&mut self) { @@ -2596,6 +2609,29 @@ impl PdfCraftApp { mod tests { use super::*; + #[test] + fn utc_stamp_is_utc_minutes_and_clamps_pre_epoch() { + use std::time::Duration; + let at = |s: u64| utc_stamp(SystemTime::UNIX_EPOCH + Duration::from_secs(s)); + assert_eq!(at(0), "1970-01-01 00:00 UTC"); + assert_eq!(at(1_791_446_357), "2026-10-08 07:59 UTC"); + // The 400-year leap rule. + assert_eq!(at(951_782_400), "2000-02-29 00:00 UTC"); + // A clock before 1970 reads as the epoch, as the old `unwrap_or(0)` did. + assert_eq!(utc_stamp(SystemTime::UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01 00:00 UTC"); + // Past chrono's range keeps the proleptic Gregorian date the old code gave, not the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(utc_stamp(far), "318857-05-20 17:46 UTC"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(utc_stamp(far), "292277026596-12-04 15:30 UTC"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(at(253_402_300_800), "10000-01-01 00:00 UTC"); + } + /// A one-page PDF that opens with "pw". fn locked() -> Vec { let objs = [ diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 612b985e6..27c97a240 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -290,19 +290,25 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st .strong(), ); if ui.small_button("›").on_hover_text(tl!("Next month")).clicked() { - (y, m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + // `y + 1` would overflow at `i32::MAX` (a date parsed or persisted with + // that year); stay put on December of the last year instead of panicking. + (y, m) = if m == 12 { y.checked_add(1).map_or((y, m), |next| (next, 1)) } else { (y, m + 1) }; } }); // Weekday of the 1st (0 = Sunday), Zeller-style via days since 1970-01-01 (a Thursday). - let days_from_civil = |y: i32, m: u32, d: u32| -> i64 { - let (y, m) = if m <= 2 { (y as i64 - 1, m as i64 + 9) } else { (y as i64, m as i64 - 3) }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * m + 2) / 5 + d as i64 - 1; - era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468 + use chrono::Datelike; + // The month the calendar shows: the weekday of its 1st (0 = Sunday) and its + // length, both from chrono. A year chrono can't represent falls back to a plain + // 31-day month starting on Sunday, which only the arrows can reach. + let first_of = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1); + let first = first_of(y, m).map_or(0, |d| d.weekday().num_days_from_sunday() as usize); + // `y + 1` for December would overflow at `i32::MAX`; a year chrono can't represent + // (or that overflows here) falls back to the plain 31-day month. + let next_y = if m == 12 { y.checked_add(1) } else { Some(y) }; + let len = match (first_of(y, m), next_y.and_then(|next| first_of(next, if m == 12 { 1 } else { m + 1 }))) { + (Some(a), Some(b)) => (b - a).num_days() as usize, + _ => 31, }; - let first = (days_from_civil(y, m, 1) + 4).rem_euclid(7) as usize; - let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize; egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7c6ca7aed..7a33bad87 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -15,3 +15,5 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } sha2 = "0.11" toml = "1" +# The demo-PDF time stamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } diff --git a/xtask/src/demo_pdf/pdf.rs b/xtask/src/demo_pdf/pdf.rs index 297d4ce93..b51ea1a64 100644 --- a/xtask/src/demo_pdf/pdf.rs +++ b/xtask/src/demo_pdf/pdf.rs @@ -75,22 +75,33 @@ impl Stamp { } pub fn from_unix(secs: i64) -> Self { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60); - // Civil-from-days (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); + let (y, m, d) = Self::civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. Stamp { unix: secs, pdf: format!("D:{y:04}{m:02}{d:02}{h:02}{mi:02}{s:02}Z"), iso: 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). + /// + /// Past `NaiveDate`'s ±262143-year range the old hand-rolled converter still produced a date, + /// which both stamps formatted directly; a `from_timestamp` epoch fallback would change it. + /// 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. 1970-01-01 is `num_days_from_ce` 719_163. + fn civil_from_days(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 + } + } + /// The same moment shifted by `minutes` (used to order threaded replies). pub fn plus_minutes(&self, minutes: i64) -> Self { Self::from_unix(self.unix + minutes * 60) @@ -429,6 +440,14 @@ mod tests { assert_eq!(s.iso, "2026-09-21T14:13:20Z"); assert_eq!(s.plus_minutes(0).pdf, s.pdf); assert_eq!(s.plus_minutes(60 * 24).iso, "2026-09-22T14:13:20Z"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(Stamp::from_unix(253_402_300_800).iso, "10000-01-01T00:00:00Z"); + assert_eq!(Stamp::from_unix(253_402_300_800).pdf, "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (no year clamp — a negative year keeps its sign). + assert_eq!(Stamp::from_unix(10_000_000_000_000).iso, "318857-05-20T17:46:40Z"); + assert_eq!(Stamp::from_unix(10_000_000_000_000).pdf, "D:3188570520174640Z"); + assert_eq!(Stamp::from_unix(-10_000_000_000_000).iso, "-314918-08-13T06:13:20Z"); } #[test]