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}