From 606c560d240f82ae246b2b03f1c159a01b8b77ea Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:46 -0400 Subject: [PATCH] Use chrono instead of hand-rolled UTC conversion (#3824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days/days_from_civil copies and the leap-year/month math around them (log timestamp, PDF /Info dates, ASN.1 UTCTime/GeneralizedTime, FormCalc Date/Num2Date, the Combine modified-time stamp, the form date picker, and the demo-PDF timestamps) with chrono as a workspace dependency. The engine's existing chrono (clock) is lifted to the workspace. Every output format is unchanged and pinned by tests. CLAUDE.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Guard chrono date paths: no panic on overflow, no '+' on 5-digit years Systemic chrono follow-ups to the UTC/calendar PR: - logging::timestamp and combine_ui::utc_stamp dropped chrono's From (which unwraps internally and panics on a clock past year 262143) for the fallible DateTime::from_timestamp, reading an out-of-range clock as the epoch, as the old clamping code did. - timestamp, utc_stamp, pdf_date and the xtask Stamp now write the year with {:04} instead of %Y, which prefixes a '+' before years past 9999; the old hand-rolled code wrote the plain zero-padded year. - forms_ui calendar navigation and length, and formcalc days_in, use checked_add for the December year successor so y + 1 can't overflow at i32::MAX / i64::MAX (reachable from a parsed or persisted date). Docstrings added to the chrono-touched civil_from_days and Time::from_unix for the docstring-coverage gate. Tests cover the out-of-range-epoch and 10000-01-01 cases for each. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Keep FormCalc dates exact past chrono's range MAX_DAYS accepts day numbers up to 1e8 (years past 270000), but NaiveDate stops near ±262143, so Num2Date(99000000) and Format fell back to 1900-01-01. days_from_civil, civil_from_days and days_in now have chrono convert within the date's 400-year Gregorian cycle and add whole cycles back (via i128), which gives the pre-chrono results for every year. Tests cover accepted inputs past chrono's range on both sides of the epoch, through Num2Date and Format, with values from the old formula. * Bump rustls-webpki and webpki-root-certs for RUSTSEC-2026-0382/0383 rustls-webpki 0.103.15 -> 0.103.16 and webpki-root-certs 1.0.9 -> 1.0.10 (both semver-compatible, lockfile only) drop the distrusted emSign intermediates and root. Only these two entries change; cargo deny check advisories passes. Upstream main fails the same check. * logging/cos/sign/combine/demo: keep extended-year dates, no epoch fallback For consistency with the stricter no-epoch-fallback rule now agreed across the sibling chrono PRs (wordcraft#3427, filmcraft#2662): `DateTime::from_timestamp(..).unwrap_or_default()` changes a timestamp the old code converted past chrono's year 262143 range — it collapses to 1970-01-01 instead of the real proleptic-Gregorian date. The FormCalc Num2Date path was already fixed (FredApps approved); this applies the same fix to every remaining conversion. Each site uses the 400-year Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts the day within one cycle, whole cycles carry in the year (i128, saturated into i64). PDF/ASN.1 format-specific year handling is kept exactly as the old code had it. Verified the cycle result equals the old formula across a wide range and at the extremes. - apps/pdfcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (was 1970); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch). - crates/cos/src/writer.rs `pdf_date` (i64): cycle converter, no year clamp (old code wrote `y` as-is, so a negative year keeps its sign). Pins: pdf_date(10_000_000_000_000)="D:3188570520174640Z", pdf_date(-10_000_000_000_000)="D:-3149180813061320Z". - crates/sign/src/der.rs `Time::from_unix` (i64): cycle converter; the `year` field is a u32, so the year is cast `as u32` (wrapping) exactly as the old code did — ASN.1 year width preserved byte-for-byte. Pins: from_unix(10e12).year=318857, from_unix(-10e12).year=4_294_652_378, from_unix(i64::MAX).year=219_250_468, from_unix(i64::MIN).year=4_075_720_767. - crates/ui-egui/src/combine_ui.rs `utc_stamp` (u64): cycle converter; pre-1970 epoch clamp kept. Pins: utc_stamp(10e12)="318857-05-20 17:46 UTC", u64::MAX/2 s="292277026596-12-04 15:30 UTC". - xtask/src/demo_pdf/pdf.rs `Stamp::from_unix` (i64): cycle converter, no year clamp. Pins: from_unix(10e12).iso="318857-05-20T17:46:40Z"/.pdf="D:3188570520174640Z", from_unix(-10e12).iso="-314918-08-13T06:13:20Z". cos/sign/xtask built, tested (pdf_dates_are_utc_and_byte_identical, der::tests::times, stamps_round_trip), fmt and clippy clean. pdfcraft-ui-egui and the pdfcraft app can't link here (unrelated LLVM intrinsic bug in the rten-gemm dependency, 1.98.1), so their logging and combine_ui functions + tests were copied verbatim into a scratch crate with chrono 0.4.45 and pass there; fmt --check clean on all five packages. CI builds these on all platforms. * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- CLAUDE.md | 1 + Cargo.lock | 6 +++ Cargo.toml | 2 + apps/pdfcraft/Cargo.toml | 2 + apps/pdfcraft/src/logging.rs | 50 ++++++++++++------ crates/cos/Cargo.toml | 2 + crates/cos/src/writer.rs | 57 ++++++++++++++++----- crates/engine/Cargo.toml | 5 +- crates/js/Cargo.toml | 3 ++ crates/js/src/formcalc.rs | 88 +++++++++++++++++++++++--------- crates/js/src/tests.rs | 11 ++++ crates/sign/Cargo.toml | 2 + crates/sign/src/der.rs | 55 ++++++++++++++------ crates/ui-egui/Cargo.toml | 3 ++ crates/ui-egui/src/combine_ui.rs | 62 +++++++++++++++++----- crates/ui-egui/src/forms_ui.rs | 24 +++++---- xtask/Cargo.toml | 2 + xtask/src/demo_pdf/pdf.rs | 43 +++++++++++----- 18 files changed, 314 insertions(+), 104 deletions(-) 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]