Files
wordcraft/crates/pdf
Dylan M. Taylor 7f38f41d8c Use chrono instead of hand-rolled UTC conversion (#3427)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies and the
Excel serial-date, FILETIME and logging/PDF date conversions with chrono as
a workspace dependency (default-features = false, std only, so wasm is
unaffected). Output formats are unchanged and pinned by the existing (and one
new) test. Excel's 1900 leap-year bug and the 1900/1904 systems keep their
explicit special cases; a small proleptic-Gregorian fallback covers years
outside chrono's NaiveDate range so no input changes behaviour or panics.
AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic or reshape date output for clocks outside chrono's range

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. logging::timestamp now uses the fallible
DateTime::from_timestamp and reads such a clock as the epoch, as the old
saturating code did.

iso_from_unix_secs fell back to the Unix epoch for timestamps chrono
rejects; it now keeps the proleptic Gregorian date civil_from_days gives,
restoring the pre-chrono result for supported out-of-range u64 inputs.

Years past 9999 are written with {:04} rather than %Y (which prepends a
'+') in logging::timestamp, iso_from_unix_secs, docbin::filetime_iso and
odt::now_iso, so byte-for-byte output is preserved.

filetime_iso gained a docstring; AGENTS.md's chrono bullet now notes the
narrow out-of-range fallback. Tests cover each boundary.

* 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.

* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies

The PR kept hand-rolled Hinnant `civil_fallback`/`days_from_civil_fallback`
copies for dates outside chrono's ±262143-year range, and AGENTS.md carved a
"narrow fallback" exception for them. That left calendar arithmetic outside
chrono after all.

Replace every copy with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so i64::MIN/MAX can't overflow). This keeps chrono as the only calendar logic
and reproduces the pre-chrono proleptic Gregorian result for every value:

- engine cmd/mod.rs `civil_from_days` (reached by the system clock and by
  `iso_from_unix_secs` for a u64 seconds count past chrono's range).
- engine cmd/controls.rs `days_from_civil` and `days_in_month` (`days_in_month`
  now uses `rem_euclid(400)` since month lengths repeat every 400 years).
- formats sheet.rs `civil` and docx chart.rs `civil` (Excel serials).

Outputs are unchanged: verified the cycle result equals the old formula over a
wide range and at the extremes with a scratch chrono program. New tests pin
accepted inputs past chrono's range on both sides of the epoch, through the
public/lib entry points and the private converters, with values from the old
formula. Removed the AGENTS.md "narrow fallback" exception.

pdf/lib.rs `civil` is left as-is: its only caller is the live system clock and
its year is clamped to 0..9999 by the PDF DateTime, so the epoch fallback past
year 262143 is the accepted live-clock behavior, not a hand-rolled copy.

Tested: cargo test -p wordcraft-engine -p wordcraft-formats -p wordcraft-docx;
fmt --check; clippy --all-targets -D warnings.

* logging/docbin/odt/pdf: keep extended-year dates, no epoch fallback

FredApps (CHANGES_REQUESTED) showed `DateTime::from_timestamp(..).unwrap_or_default()`
changes output: a timestamp the old code converted past chrono's year 262143 range now
collapsed to 1970-01-01 instead of its real proleptic-Gregorian date. The same regression
exists wherever the PR swapped a hand-rolled converter for the epoch fallback. Fix every
such site to keep chrono as the only calendar logic via the 400-year Gregorian cycle
(146_097 days): chrono converts the day within one cycle, whole cycles carry in the year
(i128, saturated into i64). Verified the cycle result equals the old formula across a wide
range and at the extremes.

- apps/wordcraft/src/logging.rs `timestamp`: added `civil_from_days(u64)` (cycle), kept the
  pre-1970 epoch clamp (`duration_since`) and the milliseconds/time-of-day format exactly.
  Pins: UNIX_EPOCH + 10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (FredApps' example,
  was 1970-...); the checked_add u64::MAX/2 s fixture now asserts 292277026596-12-04T15:30:07.000Z
  instead of the epoch.
- crates/docbin/src/metadata.rs `filetime_iso`: restored a cycle-based `civil(i64)` instead
  of `from_timestamp(..)?` (which dropped the field past chrono's range). A u64 FILETIME caps
  at ~1.83e12 unix s (year 60056), inside chrono's range, so no reachable value changes; pinned
  the largest FILETIME (u64::MAX) -> 60056-05-28T05:36:10Z to lock the extended-year path.
- crates/formats/src/odt.rs `now_iso`: reuse the crate's existing cycle converter
  `sheet::civil` (made pub(crate)) rather than the epoch fallback.
- crates/pdf/src/lib.rs `civil`: cycle converter; year returned unclamped and the caller's
  existing PDF DateTime 0..=9999 year clamp is kept exactly, so month/day/time of a far-future
  clock survive. Pins: civil(10_000_000_000_000)=(318857,5,20,17,46,40),
  civil(-10_000_000_000_000)=(-314918,8,13,6,13,20), civil(253_402_300_800)=(10000,1,1,0,0,0).

engine `cmd/mod.rs::iso_from_unix_secs` already uses the cycle fallback (last round); unchanged.

* 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.
2026-10-21 19:36:41 -04:00
..