mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
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.
This commit is contained in:
@@ -43,6 +43,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
|||||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||||
|
- **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).
|
||||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||||
|
|
||||||
## Quality gates
|
## Quality gates
|
||||||
|
|||||||
Generated
+16
-1
@@ -704,6 +704,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -2883,7 +2892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5134,6 +5143,7 @@ name = "wordcraft"
|
|||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"chrono",
|
||||||
"clipboard-win",
|
"clipboard-win",
|
||||||
"craft-print",
|
"craft-print",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
@@ -5196,6 +5206,7 @@ name = "wordcraft-docbin"
|
|||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfb",
|
"cfb",
|
||||||
|
"chrono",
|
||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
"miniz_oxide 0.8.9",
|
"miniz_oxide 0.8.9",
|
||||||
@@ -5214,6 +5225,7 @@ dependencies = [
|
|||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64",
|
||||||
"cfb",
|
"cfb",
|
||||||
|
"chrono",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"hmac",
|
"hmac",
|
||||||
"image",
|
"image",
|
||||||
@@ -5235,6 +5247,7 @@ name = "wordcraft-engine"
|
|||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfb",
|
"cfb",
|
||||||
|
"chrono",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"image",
|
"image",
|
||||||
@@ -5278,6 +5291,7 @@ version = "0.9.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfb",
|
"cfb",
|
||||||
"chardetng",
|
"chardetng",
|
||||||
|
"chrono",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"image",
|
"image",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -5339,6 +5353,7 @@ dependencies = [
|
|||||||
name = "wordcraft-pdf"
|
name = "wordcraft-pdf"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"craft-pdf",
|
"craft-pdf",
|
||||||
"hayro-cmap",
|
"hayro-cmap",
|
||||||
"hayro-interpret",
|
"hayro-interpret",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ thiserror = "2"
|
|||||||
serde = { version = "1", features = ["derive", "rc"] }
|
serde = { version = "1", features = ["derive", "rc"] }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
log = "0.4"
|
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"
|
proptest = "1"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
unicode-bidi = "0.3"
|
unicode-bidi = "0.3"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
|||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
# Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows.
|
# Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows.
|
||||||
|
|||||||
@@ -94,7 +94,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 {
|
pub fn timestamp(t: SystemTime) -> String {
|
||||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||||
let secs = since.as_secs();
|
let secs = since.as_secs();
|
||||||
@@ -103,20 +106,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())
|
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
|
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
///
|
||||||
/// value can overflow it.
|
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||||
let z = days.saturating_add(719_468);
|
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||||
let era = z / 146_097;
|
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||||
let doe = z % 146_097;
|
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
/// is `num_days_from_ce` 719_163.
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||||
let mp = (5 * doy + 2) / 153;
|
use chrono::Datelike;
|
||||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||||
(y, m, d)
|
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 {
|
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||||
@@ -435,8 +443,21 @@ mod tests {
|
|||||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
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.
|
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
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. FredApps' counterexample:
|
||||||
|
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
|
||||||
|
// 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]
|
#[test]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ miniz_oxide = { workspace = true }
|
|||||||
sha1 = { workspace = true }
|
sha1 = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = { workspace = true }
|
proptest = { workspace = true }
|
||||||
|
|||||||
@@ -87,20 +87,8 @@ fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn civil(days: i64) -> (i64, u32, u32) {
|
/// A `VT_FILETIME` property value as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`), or `None`
|
||||||
let z = days + 719_468;
|
/// if it is not a FILETIME, is zero, or predates the 1601 FILETIME epoch.
|
||||||
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
|
|
||||||
let doe = (z - era * 146_097) as u32;
|
|
||||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
|
||||||
let y = yoe as i64 + 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 = y + i64::from(m <= 2);
|
|
||||||
(y, m, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn filetime_iso(value: &[u8]) -> Option<String> {
|
fn filetime_iso(value: &[u8]) -> Option<String> {
|
||||||
let kind = u32_at(value, 0)?;
|
let kind = u32_at(value, 0)?;
|
||||||
if kind != 64 {
|
if kind != 64 {
|
||||||
@@ -114,14 +102,34 @@ fn filetime_iso(value: &[u8]) -> Option<String> {
|
|||||||
}
|
}
|
||||||
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
|
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
|
||||||
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
|
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
|
||||||
let unix_secs = unix_100ns / 10_000_000;
|
let unix_secs = i64::try_from(unix_100ns / 10_000_000).ok()?;
|
||||||
let days = (unix_secs / 86_400) as i64;
|
let (days, sod) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400));
|
||||||
let secs_of_day = (unix_secs % 86_400) as u32;
|
let (h, mi, s) = (sod / 3600, sod / 60 % 60, sod % 60);
|
||||||
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
|
||||||
let (y, m, d) = civil(days);
|
let (y, m, d) = civil(days);
|
||||||
|
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||||
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
|
Some(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).
|
||||||
|
///
|
||||||
|
/// A FILETIME can land past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter
|
||||||
|
/// still produced a date; dropping to `None` there would change the output. 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(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn text(value: &[u8], codepage: u16) -> Option<String> {
|
fn text(value: &[u8], codepage: u16) -> Option<String> {
|
||||||
let kind = u32_at(value, 0)?;
|
let kind = u32_at(value, 0)?;
|
||||||
let count = u32_at(value, 4)? as usize;
|
let count = u32_at(value, 4)? as usize;
|
||||||
@@ -151,3 +159,35 @@ fn text(value: &[u8], codepage: u16) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::filetime_iso;
|
||||||
|
|
||||||
|
/// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned
|
||||||
|
/// to what the old hand-rolled `civil` produced.
|
||||||
|
fn value(ft: u64) -> Vec<u8> {
|
||||||
|
let mut v = 64u32.to_le_bytes().to_vec();
|
||||||
|
v.extend_from_slice(&((ft & 0xffff_ffff) as u32).to_le_bytes());
|
||||||
|
v.extend_from_slice(&((ft >> 32) as u32).to_le_bytes());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filetime_to_iso() {
|
||||||
|
// 2020-01-01T00:00:00Z and the same day at 13:45:09.
|
||||||
|
assert_eq!(filetime_iso(&value(132_223_104_000_000_000)).as_deref(), Some("2020-01-01T00:00:00Z"));
|
||||||
|
assert_eq!(filetime_iso(&value(132_223_599_090_000_000)).as_deref(), Some("2020-01-01T13:45:09Z"));
|
||||||
|
// Year 10000 keeps the plain zero-padded year (no leading `+` that chrono's `%Y` would add).
|
||||||
|
assert_eq!(filetime_iso(&value(2_650_467_744_000_000_000)).as_deref(), Some("10000-01-01T00:00:00Z"));
|
||||||
|
// The largest FILETIME (u64::MAX) lands in year 60056: past year 9999 (so the plain year
|
||||||
|
// matters) and the old `civil` converted it without ever dropping to the epoch. chrono's
|
||||||
|
// own range (year 262143) isn't reachable here — a u64 FILETIME caps at unix seconds
|
||||||
|
// ~1.83e12 — but the 400-year cycle keeps the result exact regardless.
|
||||||
|
assert_eq!(filetime_iso(&value(u64::MAX)).as_deref(), Some("60056-05-28T05:36:10Z"));
|
||||||
|
// Zero, a non-FILETIME kind and times before the 1601 epoch are rejected, not a panic.
|
||||||
|
assert_eq!(filetime_iso(&value(0)), None);
|
||||||
|
assert_eq!(filetime_iso(&[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None);
|
||||||
|
assert_eq!(filetime_iso(&value(1)), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
|
|||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
unicode-bidi = { workspace = true }
|
unicode-bidi = { workspace = true }
|
||||||
cfb = { workspace = true }
|
cfb = { workspace = true }
|
||||||
aes = { workspace = true }
|
aes = { workspace = true }
|
||||||
|
|||||||
@@ -803,18 +803,27 @@ fn excel_stamp(serial: f64, date1904: bool) -> Option<Stamp> {
|
|||||||
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs })
|
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (Howard Hinnant's
|
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial
|
||||||
/// civil-from-days algorithm).
|
/// day number). Serial `n` is `n - 25_569` days from 1970-01-01.
|
||||||
|
///
|
||||||
|
/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats
|
||||||
|
/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar
|
||||||
|
/// math: chrono converts the day within its cycle and whole cycles go back into the year (an
|
||||||
|
/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n`
|
||||||
|
/// is CE day `n - 25_569 + 719_163`.
|
||||||
fn civil(n: i64) -> (i64, u32, u32) {
|
fn civil(n: i64) -> (i64, u32, u32) {
|
||||||
let z = n - 25_569 + 719_468;
|
use chrono::Datelike;
|
||||||
let era = z.div_euclid(146_097);
|
// CE day of serial `n`, in i128 so no `n` overflows.
|
||||||
let doe = z.rem_euclid(146_097);
|
let from_ce = i128::from(n) - 25_569 + 719_163;
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097));
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||||
let mp = (5 * doy + 2) / 153;
|
Some(date) => {
|
||||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
let y = i128::from(date.year()) + cycles * 400;
|
||||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
|
||||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
}
|
||||||
|
// Unreachable: every `rest` in 0..146_097 is a valid CE day.
|
||||||
|
None => (1900, 1, 1),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||||
@@ -1975,6 +1984,18 @@ mod tests {
|
|||||||
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn civil_is_exact_beyond_chronos_range() {
|
||||||
|
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
|
||||||
|
assert_eq!(civil(1), (1899, 12, 31));
|
||||||
|
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of
|
||||||
|
// the epoch; values from the pre-chrono proleptic Gregorian formula.
|
||||||
|
assert_eq!(civil(100_000_000), (275_690, 9, 11));
|
||||||
|
assert_eq!(civil(-100_000_000), (-271_891, 4, 18));
|
||||||
|
// The extremes must not overflow or panic (i128 intermediates).
|
||||||
|
let _ = (civil(i64::MAX), civil(i64::MIN));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn number_formats_keep_literal_text_and_currency() {
|
fn number_formats_keep_literal_text_and_currency() {
|
||||||
assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
|
assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
regex-automata = { workspace = true }
|
regex-automata = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
|||||||
@@ -123,23 +123,33 @@ pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn days_in_month(y: i64, m: u32) -> u32 {
|
pub fn days_in_month(y: i64, m: u32) -> u32 {
|
||||||
match m {
|
// The length of a month is the day before the first of the next month. Month lengths repeat
|
||||||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
|
||||||
2 => 28,
|
// always fits chrono, for any `y`. An invalid month keeps the old 31-day default.
|
||||||
4 | 6 | 9 | 11 => 30,
|
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));
|
||||||
|
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
|
||||||
|
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(31),
|
||||||
_ => 31,
|
_ => 31,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]).
|
/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]), exact for any year.
|
||||||
|
///
|
||||||
|
/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year
|
||||||
|
/// within its cycle and whole cycles are added back as days (an i128 so the extremes can't
|
||||||
|
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163; the result matches the proleptic
|
||||||
|
/// Gregorian count the old hand-rolled code gave.
|
||||||
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||||
let y = if m <= 2 { y - 1 } else { y };
|
let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
|
||||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
i32::try_from(year)
|
||||||
let yoe = y - era * 400;
|
.ok()
|
||||||
let mp = i64::from((m + 9) % 12);
|
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
|
.and_then(|date| {
|
||||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
|
||||||
era * 146_097 + doe - 719_468
|
i64::try_from(days).ok()
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The built-in name of the placeholder style. Its id is localized: German Word writes
|
/// The built-in name of the placeholder style. Its id is localized: German Word writes
|
||||||
@@ -750,6 +760,22 @@ mod tests {
|
|||||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||||
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
|
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() {
|
||||||
|
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the
|
||||||
|
// pre-chrono formula, both directions, both sides of the epoch.
|
||||||
|
for (y, m, d, days) in
|
||||||
|
[(262_144i64, 1u32, 1u32, 95_026_602i64), (273_022, 10, 18, 99_000_000), (-262_145, 3, 1, -96_465_964), (-271_821, 4, 21, -99_999_999)]
|
||||||
|
{
|
||||||
|
assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
|
||||||
|
assert_eq!(super::super::civil_from_days(days), (y, m, d), "{days}");
|
||||||
|
}
|
||||||
|
assert_eq!(days_in_month(272_952, 2), 29, "272952 is a leap year");
|
||||||
|
assert_eq!(days_in_month(-269_153, 2), 28);
|
||||||
|
// The extremes must not overflow or panic (i128 intermediates / `rem_euclid`).
|
||||||
|
let _ = (days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1), days_in_month(i64::MAX, 2), days_in_month(i64::MIN, 2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -422,9 +422,17 @@ fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result<Pos, CmdError
|
|||||||
|
|
||||||
/// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch.
|
/// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch.
|
||||||
pub fn iso_from_unix_secs(secs: u64) -> String {
|
pub fn iso_from_unix_secs(secs: u64) -> String {
|
||||||
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
|
use chrono::Datelike;
|
||||||
let t = secs % 86_400;
|
let t = secs % 86_400;
|
||||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
|
let hms = format!("{:02}:{:02}:{:02}", t / 3600, t / 60 % 60, t % 60);
|
||||||
|
// Within chrono's range, read the date from it; past it, keep the proleptic Gregorian answer
|
||||||
|
// `civil_from_days` gives rather than falling back to the epoch.
|
||||||
|
let (y, m, d) = match i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)) {
|
||||||
|
Some(dt) => (i64::from(dt.year()), dt.month(), dt.day()),
|
||||||
|
None => civil_from_days((secs / 86_400) as i64),
|
||||||
|
};
|
||||||
|
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||||
|
format!("{y:04}-{m:02}-{d:02}T{hms}Z")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web.
|
/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web.
|
||||||
@@ -447,18 +455,25 @@ pub fn now_iso() -> String {
|
|||||||
iso_from_unix_secs(now_unix())
|
iso_from_unix_secs(now_unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
/// Days since 1970-01-01 → (year, month, day).
|
||||||
|
///
|
||||||
|
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
|
||||||
|
/// [`iso_from_unix_secs`] for a `u64` seconds count past it), so it must stay exact for every
|
||||||
|
/// `z`. The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the day
|
||||||
|
/// within its cycle and whole cycles go back into the year (an i128 so i64::MIN/MAX can't
|
||||||
|
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163, and 0..146_097 days from CE day 0 always
|
||||||
|
/// fit chrono. The result matches the proleptic Gregorian count the old hand-rolled code gave.
|
||||||
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||||
let z = z + 719_468;
|
use chrono::Datelike;
|
||||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||||
let doe = z - era * 146_097;
|
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
Some(d) => {
|
||||||
let y = yoe + era * 400;
|
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||||
let mp = (5 * doy + 2) / 153;
|
}
|
||||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
// Unreachable: `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
None => (1970, 1, 1),
|
||||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
|
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
|
||||||
@@ -486,7 +501,7 @@ pub fn default_lang(s: &Session) -> Option<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod iso_tests {
|
mod iso_tests {
|
||||||
use super::iso_from_unix_secs;
|
use super::{civil_from_days, iso_from_unix_secs};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_epoch_and_known_timestamps() {
|
fn formats_epoch_and_known_timestamps() {
|
||||||
@@ -500,4 +515,26 @@ mod iso_tests {
|
|||||||
assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z");
|
assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z");
|
||||||
assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z");
|
assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_dates_outside_chronos_range() {
|
||||||
|
// Year 10000 is still inside chrono's range: a plain zero-padded year, no leading `+`.
|
||||||
|
assert_eq!(iso_from_unix_secs(253_402_300_800), "10000-01-01T00:00:00Z");
|
||||||
|
// Past chrono's range, keep the proleptic Gregorian date instead of the Unix epoch.
|
||||||
|
assert_eq!(iso_from_unix_secs(10_000_000_000_000), "318857-05-20T17:46:40Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn civil_from_days_is_exact_beyond_chronos_range() {
|
||||||
|
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||||
|
assert_eq!(civil_from_days(20_734), (2026, 10, 8));
|
||||||
|
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; values from the
|
||||||
|
// pre-chrono formula, both directions, both sides of the epoch.
|
||||||
|
assert_eq!(civil_from_days(95_026_602), (262_144, 1, 1));
|
||||||
|
assert_eq!(civil_from_days(99_000_000), (273_022, 10, 18));
|
||||||
|
assert_eq!(civil_from_days(-96_465_964), (-262_145, 3, 1));
|
||||||
|
assert_eq!(civil_from_days(-99_000_000), (-269_083, 3, 17));
|
||||||
|
// The extremes must not overflow or panic (i128 intermediates).
|
||||||
|
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ cfb = { workspace = true }
|
|||||||
encoding_rs = { workspace = true }
|
encoding_rs = { workspace = true }
|
||||||
chardetng = { workspace = true }
|
chardetng = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
# The browser clock (`SystemTime::now()` panics on wasm32): ODT change dates.
|
# The browser clock (`SystemTime::now()` panics on wasm32): ODT change dates.
|
||||||
|
|||||||
@@ -794,16 +794,10 @@ fn join_deleted_marks(blocks: &[FBlock]) -> Option<(Vec<FBlock>, HashMap<usize,
|
|||||||
fn now_iso() -> String {
|
fn now_iso() -> String {
|
||||||
let t = now_unix();
|
let t = now_unix();
|
||||||
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
|
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
|
||||||
// Days since 1970-01-01 → civil date (Howard Hinnant's algorithm).
|
// Reuse the crate's 400-year-cycle converter (chrono within a cycle, cycles carried in the
|
||||||
let z = days + 719_468;
|
// year) so a clock past chrono's range keeps the proleptic Gregorian date the old hand-rolled
|
||||||
let era = z.div_euclid(146_097);
|
// code gave, never the epoch. `{y:04}` rather than `%Y`, which writes a `+` before year 10000.
|
||||||
let doe = z - era * 146_097;
|
let (y, m, d) = crate::sheet::civil(days);
|
||||||
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);
|
|
||||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
|
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-11
@@ -757,17 +757,22 @@ fn serial_date(serial: f64, date1904: bool, kind: DateKind) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day).
|
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day).
|
||||||
fn civil(z: i64) -> (i64, i64, i64) {
|
///
|
||||||
let z = z + 719_468;
|
/// The caller's serial guard keeps `z` inside chrono's range, but the Gregorian calendar repeats
|
||||||
let era = z.div_euclid(146_097);
|
/// every 400 years (146_097 days), so this stays exact for any `z` without hand-rolled calendar
|
||||||
let doe = z.rem_euclid(146_097);
|
/// math: chrono converts the day within its cycle and whole cycles go back into the year (an
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163.
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
pub(crate) fn civil(z: i64) -> (i64, i64, i64) {
|
||||||
let mp = (5 * doy + 2) / 153;
|
use chrono::Datelike;
|
||||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
Some(d) => {
|
||||||
(y, m, 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 }), i64::from(d.month()), i64::from(d.day()))
|
||||||
|
}
|
||||||
|
// Unreachable: `rest + 719_163` is always a valid CE day.
|
||||||
|
None => (1970, 1, 1),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An ISO 8601 date or date-time (`2024-03-05`, `2024-03-05T13:45:00.000`) as `yyyy-mm-dd`,
|
/// An ISO 8601 date or date-time (`2024-03-05`, `2024-03-05T13:45:00.000`) as `yyyy-mm-dd`,
|
||||||
@@ -1127,4 +1132,17 @@ mod tests {
|
|||||||
assert_eq!(iso_datetime("2024-3-1").as_deref(), Some("2024-03-01"));
|
assert_eq!(iso_datetime("2024-3-1").as_deref(), Some("2024-03-01"));
|
||||||
assert_eq!(iso_datetime("2024-03-05Z").as_deref(), Some("2024-03-05"));
|
assert_eq!(iso_datetime("2024-03-05Z").as_deref(), Some("2024-03-05"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn civil_is_exact_beyond_chronos_range() {
|
||||||
|
assert_eq!(civil(0), (1970, 1, 1));
|
||||||
|
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of
|
||||||
|
// the epoch; values from the pre-chrono proleptic Gregorian formula.
|
||||||
|
assert_eq!(civil(95_026_602), (262_144, 1, 1));
|
||||||
|
assert_eq!(civil(99_000_000), (273_022, 10, 18));
|
||||||
|
assert_eq!(civil(-96_465_964), (-262_145, 3, 1));
|
||||||
|
assert_eq!(civil(-99_000_000), (-269_083, 3, 17));
|
||||||
|
// The extremes must not overflow or panic (i128 intermediates).
|
||||||
|
let _ = (civil(i64::MAX), civil(i64::MIN));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ kurbo = { workspace = true }
|
|||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
# The browser clock (`SystemTime::now()` panics on wasm32).
|
# The browser clock (`SystemTime::now()` panics on wasm32).
|
||||||
|
|||||||
+19
-12
@@ -458,19 +458,26 @@ fn now_unix() -> Option<i64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Unix seconds (UTC) → civil time (proleptic Gregorian).
|
/// Unix seconds (UTC) → civil time (proleptic Gregorian).
|
||||||
|
///
|
||||||
|
/// The year is returned unclamped (the caller clamps it to the PDF `DateTime` range 0..=9999);
|
||||||
|
/// month, day and time of day always come through correctly. Past chrono's ±262143-year range the
|
||||||
|
/// Gregorian 400-year cycle (146_097 days) keeps the result exact — chrono converts the day within
|
||||||
|
/// one cycle and whole cycles go back into the year (an `i128` so the extremes can't overflow) —
|
||||||
|
/// rather than falling back to the epoch, so the date never changes for a value the old
|
||||||
|
/// hand-rolled converter accepted. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||||
fn civil(t: i64) -> Civil {
|
fn civil(t: i64) -> Civil {
|
||||||
let days = t.div_euclid(86_400);
|
use chrono::Datelike;
|
||||||
let secs = t.rem_euclid(86_400);
|
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
|
||||||
let z = days + 719_468;
|
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||||
let era = z.div_euclid(146_097);
|
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||||
let doe = z - era * 146_097;
|
let (y, m, d) = match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
Some(date) => {
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
let y = i128::from(date.year()) + i128::from(cycles) * 400;
|
||||||
let mp = (5 * doy + 2) / 153;
|
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month() as u8, date.day() as u8)
|
||||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
}
|
||||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
None => (1970, 1, 1), // unreachable
|
||||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
};
|
||||||
(year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
|
(y, m, d, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn norm(v: f32) -> NormalizedF32 {
|
fn norm(v: f32) -> NormalizedF32 {
|
||||||
|
|||||||
@@ -447,6 +447,12 @@ fn shapes_have_paths() {
|
|||||||
assert_eq!(parse_iso("2024-02-03T04:05:06Z"), Some((2024, 2, 3, 4, 5, 6)));
|
assert_eq!(parse_iso("2024-02-03T04:05:06Z"), Some((2024, 2, 3, 4, 5, 6)));
|
||||||
assert_eq!(parse_iso("garbage"), None);
|
assert_eq!(parse_iso("garbage"), None);
|
||||||
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
|
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
|
||||||
|
// Past chrono's ±262143-year range `civil` keeps the proleptic Gregorian date the old
|
||||||
|
// hand-rolled converter gave (only the caller's 0..=9999 PDF year clamp applies later), rather
|
||||||
|
// than falling back to the epoch. Values pinned from the old formula.
|
||||||
|
assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40));
|
||||||
|
assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
|
||||||
|
assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user