mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-22 19:08:29 +00:00
Use chrono instead of hand-rolled UTC conversion (#3824)
* 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<SystemTime> (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.
This commit is contained in:
1 parent
6898094e77
commit
606c560d24
18 files changed
+314
-104
No files matched your search
@@ -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 }
|
||||
+31
-12
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user