Skip to main content

pdfrum_form/script/
zone.rs

1//! The timezone the engine's `Date` sees.
2//!
3//! **`Date` and `util.printd` do not share a zone**, and the difference is
4//! structural rather than a bug in either — see [`super::GOLDEN_PRINTD_OFFSET_SECS`].
5//! `util.printd` goes through `FX_LocalTime`, whose daylight term is
6//! `GetDaylightSavingTA` reading `tm_isdst` from `FXSYS_localtime`
7//! (`fxjs/fx_date_helpers.cpp:54-67`) — and `pdfium_test` replaces that hook
8//! with `gmtime` (`testing/pdfium_test/pdfium_test.cc:2134`), so the term is
9//! always zero and printd's shift is a flat standard offset. The engine's
10//! `Date` is **not** hooked: V8 resolves `TZ=America/Los_Angeles` through the
11//! real zone database, per instant, daylight saving included.
12//!
13//! That is why this module exists. A flat offset here is right for July and
14//! an hour wrong for December, and the error is visible in the goldens: five
15//! `util_printd_expected.txt` lines are winter dates, and one of them
16//! (`new Date(2525, 11, 31)`) is an hour before midnight, so the hour error
17//! prints as `12/30/2525` for an expected `12/31/2525`.
18
19/// How a zone's daylight-saving term is decided.
20///
21/// A rule rather than a number because the answer depends on the instant
22/// being converted, which is the whole distinction this module draws.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Daylight {
25    /// No daylight saving, ever. An embedder's fixed-offset zone, and what a
26    /// caller that only has one number can honestly say. The default.
27    #[default]
28    Never,
29    /// The United States federal rule, as the zone database records it for
30    /// `America/Los_Angeles`: the pre-1883 solar offset, no daylight saving
31    /// before 1918, the April-to-October rule to 2006, and the
32    /// March-to-November rule after it.
33    UnitedStates,
34}
35
36/// The eras of the United States federal daylight-saving rule that this
37/// implementation distinguishes.
38///
39/// **The middle of the twentieth century is deliberately not modelled.**
40/// Between the 1918 introduction and the 1987 federal settlement the observed
41/// dates were a patchwork of national wartime and local choices that no
42/// closed-form rule reproduces; a table of them is a zone database, which is
43/// not a dependency this crate takes for a handful of fixture dates. The eras
44/// below are the ones the corpus reaches — `new Date(1900, ...)`, the
45/// 2013-2015 dates, and `new Date(2525, 11, 31)` — and the unmodelled span
46/// answers with the pre-2007 rule, which is the closest single rule to it.
47/// (The pre-1883 solar era is handled before these, by
48/// [`LOS_ANGELES_LOCAL_MEAN_TIME_SECS`].)
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum UsFederal {
51    /// Before 1918: the United States kept no daylight saving at all, and
52    /// `America/Los_Angeles` is standard time year round. `new Date(1900, 06,
53    /// 04, ...)` is a July date that is nonetheless **not** shifted, which is
54    /// what `util_printd_expected.txt:44` records as `07/04/1900 15:59:58`.
55    NoDaylight,
56    /// 1918 through 2006: the pre-Energy-Policy-Act rule, daylight saving
57    /// from the first Sunday in April to the last Sunday in October.
58    FirstSundayInAprilToLastSundayInOctober,
59    /// 2007 onward: the Energy Policy Act of 2005 rule, daylight saving from
60    /// the second Sunday in March to the first Sunday in November. The rule
61    /// still in force, so it is the one a far-future date such as
62    /// `new Date(2525, 11, 31)` gets.
63    SecondSundayInMarchToFirstSundayInNovember,
64}
65
66impl UsFederal {
67    /// The rule in force in `year`.
68    fn in_force(year: i64) -> UsFederal {
69        if year < 1918 {
70            UsFederal::NoDaylight
71        } else if year < 2007 {
72            UsFederal::FirstSundayInAprilToLastSundayInOctober
73        } else {
74            UsFederal::SecondSundayInMarchToFirstSundayInNovember
75        }
76    }
77}
78
79/// `America/Los_Angeles`'s local mean time, in seconds east of UTC:
80/// −7:52:58, the city's solar offset.
81///
82/// **Before standard time there were no time zones**, and the zone database
83/// records the city's own solar offset for every instant before the railroads
84/// adopted the meridian hours. V8 reads that record, so
85/// `new Date(1850, 0, 1)` is 07:52:58 UTC and not 08:00:00 — which
86/// `util.printd`, shifting a flat eight hours back, prints as
87/// `12/31/1849` rather than `01/01/1850`
88/// (`util_printd_expected.txt:32`). Two minutes of arc across the
89/// midnight boundary, and the golden records it.
90const LOS_ANGELES_LOCAL_MEAN_TIME_SECS: i32 = -(7 * 3600 + 52 * 60 + 58);
91
92/// The instant `America/Los_Angeles` left local mean time for `GMT-0800`:
93/// 1883-11-18 at noon standard time, the Day of Two Noons, when the North
94/// American railroads adopted the meridian zones.
95const LOS_ANGELES_STANDARD_TIME_ADOPTED: i64 = -2_717_640_000;
96
97/// A local zone: a standard offset plus a rule for the daylight term.
98///
99/// Not a bare `i32`, because the two halves answer different questions and
100/// only one of them depends on the instant.
101///
102/// [`Default`] is [`Zone::UTC`].
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub struct Zone {
105    /// Seconds east of UTC in standard time. `-28800` is `GMT-0800`, which is
106    /// `America/Los_Angeles`'s.
107    pub standard_offset_secs: i32,
108    /// Whether, and how, an hour is added in summer.
109    pub daylight: Daylight,
110}
111
112impl Zone {
113    /// A zone that is the same offset all year — an embedder's, and the
114    /// honest answer when all a caller has is one number.
115    pub const fn fixed(offset_secs: i32) -> Zone {
116        Zone {
117            standard_offset_secs: offset_secs,
118            daylight: Daylight::Never,
119        }
120    }
121
122    /// `America/Los_Angeles`: `GMT-0800` standard, United States rule.
123    ///
124    /// The zone `pdfium_test` runs under (`TZ=America/Los_Angeles`, set by
125    /// `testing/tools/common.py`), and therefore the one the goldens record.
126    pub const LOS_ANGELES: Zone = Zone {
127        standard_offset_secs: -8 * 3600,
128        daylight: Daylight::UnitedStates,
129    };
130
131    /// UTC: no offset and no daylight saving. [`Default`]'s answer, and the
132    /// one a session with no configured zone gets.
133    pub const UTC: Zone = Zone::fixed(0);
134
135    /// The offset in seconds east of UTC that applies at `unix_time_seconds`.
136    pub fn offset_secs_at(self, unix_time_seconds: i64) -> i32 {
137        match self.daylight {
138            Daylight::Never => self.standard_offset_secs,
139            Daylight::UnitedStates => {
140                if unix_time_seconds < LOS_ANGELES_STANDARD_TIME_ADOPTED {
141                    LOS_ANGELES_LOCAL_MEAN_TIME_SECS
142                } else if self.is_daylight(unix_time_seconds) {
143                    self.standard_offset_secs + 3600
144                } else {
145                    self.standard_offset_secs
146                }
147            }
148        }
149    }
150
151    /// Whether daylight saving is in force at `unix_time_seconds`.
152    ///
153    /// The transitions are 02:00 **local standard** time in spring and 02:00
154    /// local daylight time in autumn; both are evaluated against the instant
155    /// shifted by the standard offset, which puts the autumn boundary an hour
156    /// early. That hour is the ambiguous repeated hour, and no golden line
157    /// lands in it.
158    fn is_daylight(self, unix_time_seconds: i64) -> bool {
159        /// 02:00 local standard time, when both transitions happen.
160        const TWO_AM: i64 = 2 * 3600;
161
162        let local = unix_time_seconds + i64::from(self.standard_offset_secs);
163        let (year, month, day, seconds_into_day) = civil_from_unix(local);
164        let rule = UsFederal::in_force(year);
165        let (start, end) = match rule {
166            UsFederal::NoDaylight => return false,
167            UsFederal::FirstSundayInAprilToLastSundayInOctober => {
168                ((4, nth_sunday(year, 4, 1)), (10, last_sunday(year, 10)))
169            }
170            UsFederal::SecondSundayInMarchToFirstSundayInNovember => {
171                ((3, nth_sunday(year, 3, 2)), (11, nth_sunday(year, 11, 1)))
172            }
173        };
174        let after_start = (month, day, seconds_into_day) >= (start.0, start.1, TWO_AM);
175        let before_end = (month, day, seconds_into_day) < (end.0, end.1, TWO_AM);
176        after_start && before_end
177    }
178}
179
180/// The day of `month` in `year` that is the `n`th Sunday of it.
181fn nth_sunday(year: i64, month: u32, n: u32) -> u32 {
182    let first_weekday = weekday_of(year, month, 1);
183    // Days from the 1st to the first Sunday, then whole weeks.
184    let first_sunday = 1 + (7 - first_weekday) % 7;
185    first_sunday + (n - 1) * 7
186}
187
188/// The day of `month` in `year` that is its last Sunday.
189fn last_sunday(year: i64, month: u32) -> u32 {
190    let last = days_in_month(year, month);
191    last - weekday_of(year, month, last)
192}
193
194/// The weekday of a civil date, 0 for Sunday.
195fn weekday_of(year: i64, month: u32, day: u32) -> u32 {
196    let days = days_from_civil(year, month, day);
197    // 1970-01-01 was a Thursday, weekday 4.
198    u32::try_from((days + 4).rem_euclid(7)).unwrap_or(0)
199}
200
201/// Whether `year` is a leap year in the proleptic Gregorian calendar.
202fn is_leap(year: i64) -> bool {
203    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
204}
205
206/// The number of days in `month` of `year`.
207fn days_in_month(year: i64, month: u32) -> u32 {
208    match month {
209        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
210        4 | 6 | 9 | 11 => 30,
211        _ if is_leap(year) => 29,
212        _ => 28,
213    }
214}
215
216/// Days since 1970-01-01 for a proleptic Gregorian civil date.
217///
218/// Howard Hinnant's `days_from_civil`, which is exact for every year this
219/// crate can be handed, negative ones included.
220fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
221    let month = i64::from(month);
222    let day = i64::from(day);
223    let year = year - i64::from(month <= 2);
224    let era = if year >= 0 { year } else { year - 399 } / 400;
225    let year_of_era = year - era * 400;
226    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
227    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
228    era * 146_097 + day_of_era - 719_468
229}
230
231/// The civil date and second-of-day of a unix instant: `(year, month, day,
232/// seconds_into_day)`, the inverse of [`days_from_civil`].
233fn civil_from_unix(seconds: i64) -> (i64, u32, u32, i64) {
234    let days = seconds.div_euclid(86_400);
235    let seconds_into_day = seconds.rem_euclid(86_400);
236    let z = days + 719_468;
237    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
238    let day_of_era = z - era * 146_097;
239    let year_of_era =
240        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
241    let year = year_of_era + era * 400;
242    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
243    let mp = (5 * day_of_year + 2) / 153;
244    let day = u32::try_from(day_of_year - (153 * mp + 2) / 5 + 1).unwrap_or(1);
245    let month = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
246    (year + i64::from(month <= 2), month, day, seconds_into_day)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    /// The calendar round-trips, which is what every rule below rests on.
254    #[test]
255    fn the_civil_calendar_round_trips() {
256        for &(year, month, day) in &[
257            (1849, 12, 31),
258            (1900, 7, 4),
259            (1970, 1, 1),
260            (2014, 7, 4),
261            (2525, 12, 31),
262            (0, 3, 1),
263        ] {
264            let days = days_from_civil(year, month, day);
265            assert_eq!(
266                civil_from_unix(days * 86_400),
267                (year, month, day, 0),
268                "{year}-{month}-{day}"
269            );
270        }
271    }
272
273    /// 1970-01-01 was a Thursday, and 2014-07-04 a Friday — the second is the
274    /// date every `util_printd` line but five is built from.
275    #[test]
276    fn weekdays_are_the_calendars_own() {
277        assert_eq!(weekday_of(1970, 1, 1), 4);
278        assert_eq!(weekday_of(2014, 7, 4), 5);
279        // 2525-12-31 is a Monday, far outside any table a lookup could hold.
280        assert_eq!(weekday_of(2525, 12, 31), 1);
281    }
282
283    /// The two transition-date rules, at the years either side of the 2007
284    /// change.
285    #[test]
286    fn the_transition_days_are_the_federal_rules() {
287        // 2006: first Sunday in April was the 2nd, last Sunday in October the 29th.
288        assert_eq!(nth_sunday(2006, 4, 1), 2);
289        assert_eq!(last_sunday(2006, 10), 29);
290        // 2014: second Sunday in March was the 9th, first Sunday in November the 2nd.
291        assert_eq!(nth_sunday(2014, 3, 2), 9);
292        assert_eq!(nth_sunday(2014, 11, 1), 2);
293    }
294
295    /// **The regression this module exists for.** Every `util_printd` date
296    /// whose expected line the flat `GMT-0700` offset got wrong, pinned by
297    /// its own instant rather than by today's — the bug was invisible for two
298    /// days because nothing in the pass depended on the calendar, and then a
299    /// board run recorded `12/30/2525` for an expected `12/31/2525`.
300    ///
301    /// The instants are UTC seconds for the local wall-clock times the
302    /// fixture's `new Date(...)` calls name, under `America/Los_Angeles`.
303    #[test]
304    fn los_angeles_is_standard_time_in_winter_and_in_1900() {
305        let winter = [
306            // 2525-12-31 00:00:00 PST.
307            (
308                days_from_civil(2525, 12, 31) * 86_400 + 8 * 3600,
309                "2525-12-31",
310            ),
311            // 1900-07-04 15:59:58 — July, but before the United States had
312            // daylight saving at all.
313            (
314                days_from_civil(1900, 7, 4) * 86_400 + 15 * 3600 + 59 * 60 + 58 + 8 * 3600,
315                "1900-07-04",
316            ),
317            // 2015-12-09, 2014-03-02 and 2013-12-30, the other three.
318            (
319                days_from_civil(2015, 12, 9) * 86_400 + 8 * 3600,
320                "2015-12-09",
321            ),
322            (
323                days_from_civil(2014, 3, 2) * 86_400 + 8 * 3600,
324                "2014-03-02",
325            ),
326            (
327                days_from_civil(2013, 12, 30) * 86_400 + 8 * 3600,
328                "2013-12-30",
329            ),
330        ];
331        for (instant, label) in winter {
332            assert_eq!(
333                Zone::LOS_ANGELES.offset_secs_at(instant),
334                -8 * 3600,
335                "{label} is standard time"
336            );
337        }
338    }
339
340    /// And daylight time in summer, which is the offset the other 52 lines
341    /// and the frozen seed itself were recorded under.
342    #[test]
343    fn los_angeles_is_daylight_time_in_summer() {
344        for (instant, label) in [
345            (
346                days_from_civil(2014, 7, 4) * 86_400 + 7 * 3600,
347                "2014-07-04",
348            ),
349            // The frozen clock, 2014-05-09 — `the_timezone_is_pdfiums_own`
350            // asserts the 420-minute answer this produces.
351            (super::super::GOLDEN_CLOCK_SECS.cast_signed(), "the seed"),
352            (
353                days_from_civil(2015, 9, 4) * 86_400 + 7 * 3600,
354                "2015-09-04",
355            ),
356        ] {
357            assert_eq!(
358                Zone::LOS_ANGELES.offset_secs_at(instant),
359                -7 * 3600,
360                "{label} is daylight time"
361            );
362        }
363    }
364
365    /// Before 1883 the offset is the city's solar one, which is the other
366    /// midnight-boundary golden line: `new Date(1850, 0, 1)` prints
367    /// `12/31/1849`, not `01/01/1850`.
368    #[test]
369    fn los_angeles_kept_local_mean_time_before_the_railroads() {
370        // 1850-01-01 00:00:00 local mean time.
371        let instant = days_from_civil(1850, 1, 1) * 86_400 + 7 * 3600 + 52 * 60 + 58;
372        assert_eq!(
373            Zone::LOS_ANGELES.offset_secs_at(instant),
374            LOS_ANGELES_LOCAL_MEAN_TIME_SECS
375        );
376        // And 1900 is already standard time, an era later.
377        let nineteen_hundred = days_from_civil(1900, 7, 4) * 86_400 + 8 * 3600;
378        assert_eq!(
379            Zone::LOS_ANGELES.offset_secs_at(nineteen_hundred),
380            -8 * 3600
381        );
382    }
383
384    /// A fixed zone answers the same for every instant, which is what an
385    /// embedder that has one number gets.
386    #[test]
387    fn a_fixed_zone_never_shifts() {
388        let zone = Zone::fixed(3600);
389        assert_eq!(zone.offset_secs_at(0), 3600);
390        assert_eq!(
391            zone.offset_secs_at(days_from_civil(2014, 7, 4) * 86_400),
392            3600
393        );
394    }
395}