diff --git a/AGENTS.md b/AGENTS.md index 0b6171cd3..61f141079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **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). - **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). diff --git a/CLAUDE.md b/CLAUDE.md index 899f22e39..cbf9de129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). +- **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). - **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). diff --git a/Cargo.lock b/Cargo.lock index 4daf5fd2d..9cea5c99c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -712,6 +712,15 @@ dependencies = [ "rand_core", ] +[[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.4.4" @@ -4569,6 +4578,7 @@ version = "0.14.0" dependencies = [ "arboard", "block2 0.6.2", + "chrono", "clipboard-win", "craft-print", "craft-window", @@ -4678,6 +4688,7 @@ dependencies = [ name = "vectorcraft-doc" version = "0.14.0" dependencies = [ + "chrono", "hayro-ccitt", "image", "jpeg-encoder", @@ -4846,6 +4857,7 @@ name = "vectorcraft-pdf" version = "0.14.0" dependencies = [ "aes", + "chrono", "craft-pdf", "flate2", "image", diff --git a/Cargo.toml b/Cargo.toml index afd89a8d8..725ae7ce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,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" crc32fast = "1.5" # tests: a TCP port bound without listening (connections to it are refused) diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index e2ba95d64..196e1679c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -27,6 +27,7 @@ eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/vectorcraft/src/logging.rs b/apps/vectorcraft/src/logging.rs index cd2ea1eba..a9fded642 100644 --- a/apps/vectorcraft/src/logging.rs +++ b/apps/vectorcraft/src/logging.rs @@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// 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) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + 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.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -387,6 +395,20 @@ mod tests { 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. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + 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"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // 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"); + } } #[test] diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 5260be910..0ee88bf3b 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -20,6 +20,7 @@ zune-jpeg = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [dev-dependencies] jpeg-encoder = { workspace = true } diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 708cb82e8..1a84ef219 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -3,6 +3,7 @@ //! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved //! before File Info existed load unchanged. +use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; /// File Info → Copyright Status. @@ -118,23 +119,26 @@ pub fn now_unix() -> Option { None } -/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic -/// Gregorian; years clamped to 0–9999). +/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years +/// clamped to 0–9999). pub fn civil(t: i64) -> [i64; 6] { - // Clamp first so every step stays in range for any input. + // Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31), + // exactly as the old code did. The clamped instant is always inside chrono's range, so the + // conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp. let t = t.clamp(-62_167_219_200, 253_402_300_799); - 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.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60] + let (days, sod) = (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` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(tm)) => { + let year = i128::from(d.year()) + i128::from(cycles) * 400; + let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX }); + [year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())] + } + _ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date + } } /// Unix seconds → `2026-10-05T14:03:00Z`. @@ -151,8 +155,15 @@ mod tests { fn dates_and_statuses() { assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z"); + // The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999, + // exactly as the old code did: inputs far past chrono's range land on the clamp bounds, + // never on the epoch. assert_eq!(civil(i64::MIN)[0], 0); assert_eq!(civil(i64::MAX)[0], 9999); + assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z"); + assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z"); + assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z"); + assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z"); assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("maybe"), None); diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index a9c110aee..6dca09aa3 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -24,6 +24,7 @@ image = { workspace = true } jpeg-encoder = { workspace = true } craft-pdf = { workspace = true } log = { workspace = true } +chrono = { workspace = true } smallvec = "1" weezl = "0.1" serde = { workspace = true } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index c8e7e9488..983a7dcea 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -234,14 +234,8 @@ fn pdf_date(d: DateTime) -> Option { /// Whether `day` is a day of `month` (1 to 12) in `year` (proleptic Gregorian). fn in_month(year: i64, month: i64, day: i64) -> bool { - let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let last = match month { - 2 if leap => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - (1..=last).contains(&day) + let ymd = i32::try_from(year).ok().zip(u32::try_from(month).ok()).zip(u32::try_from(day).ok()); + ymd.is_some_and(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d).is_some()) } /// An XMP date (ISO 16684-1: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, then optionally `Thh:mm`, `:ss` with @@ -282,13 +276,16 @@ fn xmp_date(s: &str) -> Option { } /// `[year, month, day, hour, minute, second]` at a UTC offset of `offset` minutes → Unix seconds -/// (days-from-civil, proleptic Gregorian: the inverse of [`vectorcraft_doc::metadata::civil`]). +/// (the inverse of [`vectorcraft_doc::metadata::civil`]). The date has already passed [`in_month`], +/// so it is a real day; an impossible one reads as the epoch rather than panicking. fn unix([year, month, day, hour, minute, second]: [i64; 6], offset: i64) -> i64 { - let y = if month <= 2 { year - 1 } else { year }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; + let days = i32::try_from(year) + .ok() + .zip(u32::try_from(month).ok()) + .zip(u32::try_from(day).ok()) + .and_then(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d)) + .zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1)) + .map(|(date, epoch)| (date - epoch).num_days()) + .unwrap_or(0); days * 86_400 + hour * 3600 + minute * 60 + second - offset * 60 }