Browse / github.com/storytold/indd-utils / patch
Use chrono instead of hand-rolled UTC conversion
Use chrono instead of hand-rolled UTC conversion ## Summary Follow-up to storytold/cadcraft#1785 (reference PR storytold/cadcraft#1789): replace this repo's hand-rolled Howard-Hinnant `days_from_civil`/`civil_from_days` (and the day-of-year helper built on them) with the `chrono` crate, so no module carries its own calendar algorithm. Output is byte-identical — this is a refactoring and `diff_outputs.py` should show no differing outputs. `indd` is a single crate (not a workspace), so chrono is added to it directly, std-only so the WebAssembly build stays byte-for-byte identical to native: ```toml # All date/time conversion goes through ch
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dylanmtaylor (imported from pull request #12, open)
- Base
d249788285ac083fd29f3150695c9a21337caadc- Size
- +123 −18 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f11d59f7abf9c2a1b64f9a73
How it went
No other operator has reported using it yet.
Diff
raw · CONTRIBUTING.md Cargo.lock Cargo.toml src/model/xmp.rs
@@ -33,6 +33,13 @@ accepted. content. - **Treat sample files as untrusted.** Run Python tools with `-I`, keep downloads in their own directory, and keep scratch scripts out of it.+- **Time goes through `chrono`.** Never hand-roll UTC/calendar conversion+ (days-to-date algorithms, leap-year math); a module 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). ## Setting up @@ -2,6 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]]+name = "autocfg"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"++[[package]]+name = "chrono"+version = "0.4.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"+dependencies = [+ "num-traits",+]+ [[package]] name = "indd" version = "0.1.0"+dependencies = [+ "chrono",+]++[[package]]+name = "num-traits"+version = "0.2.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"+dependencies = [+ "autocfg",+]@@ -8,3 +8,6 @@ repository = "https://github.com/aiongg/indd-utils" publish = false [dependencies]+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; CONTRIBUTING.md).+# std-only (no clock/wasmbind) so the wasm build stays byte-for-byte identical to native.+chrono = { version = "0.4.45", default-features = false, features = ["std"] }@@ -189,28 +189,72 @@ fn parse(s: &str) -> Option<XmpDate> { } /// Days since 1970-01-01 of a date in the proleptic Gregorian calendar.+///+/// `chrono`'s `NaiveDate` only reaches about year ±262143, but the Gregorian+/// calendar repeats every 400 years (`DAYS_PER_400_YEARS`), so `chrono`+/// converts the year within its cycle and the whole cycles are added back as+/// days; the result matches the proleptic Gregorian count for every year, the+/// way the former Howard-Hinnant arithmetic did. All the intermediates are+/// `i128` so a near-`i64::MAX` year can't overflow; a count that then doesn't+/// fit `i64` (no representable answer) reads as 0.+///+/// A day past the month's length (a malformed XMP date that `parse` lets+/// through, e.g. "February 31") carries forward from the first of the month,+/// exactly as the former arithmetic did (it was linear in the day). pub fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {- let y = if month <= 2 { year - 1 } else { year };- let era = y.div_euclid(400);- let yoe = y - era * 400;- let mp = (month + 9) % 12;- let doy = (153 * mp + 2) / 5 + day - 1;- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;- era * 146_097 + doe - 719_468+ use chrono::Datelike;+ let (cycles, y400) = (i128::from(year.div_euclid(400)), year.rem_euclid(400));+ // `y400` is 0..=399 and `month` 1..=12, so this is always in `chrono`'s range.+ let base = i32::try_from(y400).ok().and_then(|y| {+ u32::try_from(month)+ .ok()+ .and_then(|m| chrono::NaiveDate::from_ymd_opt(y, m, 1))+ });+ match base {+ Some(first) => {+ let days = i128::from(first.num_days_from_ce()) - i128::from(UNIX_EPOCH_DAYS_FROM_CE)+ + cycles * i128::from(DAYS_PER_400_YEARS)+ + i128::from(day)+ - 1;+ i64::try_from(days).unwrap_or(0)+ }+ None => 0,+ } } -/// The date (year, month, day) of a day count since 1970-01-01.+/// `NaiveDate::num_days_from_ce` of 1970-01-01, so the count can be rebased+/// onto the Unix epoch.+const UNIX_EPOCH_DAYS_FROM_CE: i64 = 719_163;++/// Days in one 400-year Gregorian cycle; the calendar repeats after it.+const DAYS_PER_400_YEARS: i64 = 146_097;++/// The date (year, month, day) of a day count since 1970-01-01, for any `i64`.+///+/// `chrono`'s `NaiveDate` only reaches about year ±262143, so `chrono` converts+/// the day within its 400-year Gregorian cycle (`DAYS_PER_400_YEARS`) and the+/// whole cycles are added back as years (an `i128` so `i64::MAX`/`MIN` can't+/// overflow); the result matches the proleptic Gregorian date for every count,+/// the way the former Howard-Hinnant arithmetic did. pub fn civil_from_days(days: i64) -> (i64, i64, i64) {- let z = days + 719_468;- let era = z.div_euclid(146_097);- let doe = z - era * 146_097;- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);- let mp = (5 * doy + 2) / 153;- let day = doy - (153 * mp + 2) / 5 + 1;- let month = if mp < 10 { mp + 3 } else { mp - 9 };- let year = yoe + era * 400 + i64::from(month <= 2);- (year, month, day)+ use chrono::Datelike;+ // CE day number of the date, in `i128` so no `days` overflows.+ let from_ce = i128::from(days) + i128::from(UNIX_EPOCH_DAYS_FROM_CE);+ let per_cycle = i128::from(DAYS_PER_400_YEARS);+ let (cycles, rest) = (from_ce.div_euclid(per_cycle), from_ce.rem_euclid(per_cycle));+ // `rest` is 0..146_097, a CE day inside years 0..=400, always in `chrono`'s range.+ let date = i32::try_from(rest)+ .ok()+ .and_then(chrono::NaiveDate::from_num_days_from_ce_opt);+ match date {+ Some(d) => {+ let year = i128::from(d.year()) + cycles * 400;+ let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });+ (year, i64::from(d.month()), i64::from(d.day()))+ }+ // Unreachable: every `rest` in 0..146_097 is a valid CE day.+ None => (1970, 1, 1),+ } } /// The day of the year (0-based) of an instant, in UTC.@@ -262,5 +306,29 @@ mod tests { assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29)); assert_eq!(civil_from_days(days_from_civil(1601, 1, 1)), (1601, 1, 1)); assert_eq!(day_of_year(days_from_civil(2023, 12, 31) * 86_400), 364);+ // Past chrono's own ±262143 date range the 400-year cycles carry the year, so accepted+ // counts keep the dates the pre-chrono (Hinnant) arithmetic gave. Values from the old+ // formula, in both directions and on both sides of the epoch.+ for (days, date) in [+ (95_026_602, (262_144, 1, 1)),+ (108_853_222, (300_000, 1, 1)),+ (100_000_000, (275_760, 9, 13)),+ (-96_465_659, (-262_145, 12, 31)),+ (-110_292_278, (-300_000, 1, 1)),+ (-100_000_000, (-271_821, 4, 20)),+ ] {+ assert_eq!(civil_from_days(days), date, "{days}");+ assert_eq!(days_from_civil(date.0, date.1, date.2), days, "{date:?}");+ }+ // The day still carries linearly past chrono's range (a malformed "February 31").+ assert_eq!(+ civil_from_days(days_from_civil(300_000, 2, 31)),+ (300_000, 3, 2)+ );+ // 300000 is a leap year (divisible by 400), so 300000-12-31 is 0-based day 365.+ assert_eq!(day_of_year(days_from_civil(300_000, 12, 31) * 86_400), 365);+ // The i64 extremes stay exact (i128 intermediates) rather than panicking or clamping.+ assert_eq!(civil_from_days(i64::MAX), (25_252_734_927_768_524, 7, 27));+ assert_eq!(civil_from_days(i64::MIN), (-25_252_734_927_764_585, 6, 7)); } }