Skip to main content

retex_engine/
clock.rs

1//! The job's start time: `\time`, `\day`, `\month`, `\year`, the log
2//! banner and the PDF creation date.
3//!
4//! LuaTeX takes the time once, at startup: from `SOURCE_DATE_EPOCH` when it
5//! is set (with or without `FORCE_SOURCE_DATE`), otherwise from the clock.
6//! It converts it to local time, or to UTC with the `-utc` option. Dates in
7//! PDF format carry the UTC offset (`D:19700101010000+01'00'` for epoch 0 in
8//! Central Europe, `D:19700101000000Z` with `TZ=UTC` or `-utc`).
9//!
10//! The local offset is found the way the C library finds it: from `$TZ` (a
11//! zone name, optionally prefixed with `:`, or a POSIX TZ string), else from
12//! `/etc/localtime`, reading the binary TZif files (RFC 8536) and their POSIX
13//! TZ footer rules (POSIX.1, *TZ* variable) for times after the last
14//! transition. Anything unreadable falls back to UTC.
15
16/// Where zone files live unless `$TZDIR` says otherwise.
17const ZONEINFO: &str = "/usr/share/zoneinfo";
18
19/// An instant together with the UTC offset in effect there.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct StartTime {
22    /// Seconds since 1970-01-01 00:00:00 UTC.
23    pub unix: i64,
24    /// Offset of the time zone used, in seconds east of UTC.
25    pub offset: i64,
26}
27
28/// A broken-down date and time.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct Civil {
31    /// The year.
32    pub year: i64,
33    /// The month, 1–12.
34    pub month: i64,
35    /// The day of the month, 1–31.
36    pub day: i64,
37    /// The hour, 0–23.
38    pub hour: i64,
39    /// The minute, 0–59.
40    pub minute: i64,
41    /// The second, 0–59.
42    pub second: i64,
43}
44
45impl StartTime {
46    /// The start time of a job: `epoch` (from `SOURCE_DATE_EPOCH`) or the
47    /// current time, in the local time zone or, if `utc`, in UTC.
48    pub fn new(epoch: Option<i64>, utc: bool) -> StartTime {
49        let unix = epoch.unwrap_or_else(now);
50        StartTime {
51            unix,
52            offset: if utc { 0 } else { local_offset(unix) },
53        }
54    }
55
56    /// The local date and time.
57    pub fn civil(&self) -> Civil {
58        let local = self.unix.saturating_add(self.offset);
59        let (year, month, day) = civil_from_days(local.div_euclid(86400));
60        let secs = local.rem_euclid(86400);
61        Civil {
62            year,
63            month,
64            day,
65            hour: secs / 3600,
66            minute: secs / 60 % 60,
67            second: secs % 60,
68        }
69    }
70
71    /// The date in PDF format, as `pdf.getcreationdate()` and
72    /// `\pdffeedback creationdate` return it and as the Info dictionary
73    /// contains it: `D:YYYYMMDDHHmmSS` followed by `Z` or the offset as
74    /// `+HH'mm'`.
75    pub fn pdf_date(&self) -> String {
76        let c = self.civil();
77        let mut s = format!(
78            "D:{:04}{:02}{:02}{:02}{:02}{:02}",
79            c.year, c.month, c.day, c.hour, c.minute, c.second
80        );
81        if self.offset == 0 {
82            s.push('Z');
83        } else {
84            let sign = if self.offset < 0 { '-' } else { '+' };
85            let o = self.offset.abs() / 60;
86            s.push_str(&format!("{sign}{:02}'{:02}'", o / 60, o % 60));
87        }
88        s
89    }
90}
91
92/// The random seed of a job that does not set one.
93///
94/// LuaTeX 1.22 takes the clock's microseconds times 1000 plus its seconds
95/// modulo 1000 (`\randomseed` is 720667377 at 1792245377.720667 s), also
96/// when `SOURCE_DATE_EPOCH` is set, so the default sequence differs from
97/// run to run.
98pub fn startup_random_seed() -> i32 {
99    let d = std::time::SystemTime::now()
100        .duration_since(std::time::UNIX_EPOCH)
101        .unwrap_or_default();
102    (i64::from(d.subsec_micros()) * 1000 + (d.as_secs() % 1000) as i64) as i32
103}
104
105/// The current time in seconds since the epoch.
106pub fn now() -> i64 {
107    std::time::SystemTime::now()
108        .duration_since(std::time::UNIX_EPOCH)
109        .map_or(0, |d| d.as_secs() as i64)
110}
111
112/// `SOURCE_DATE_EPOCH` from the environment, interpreted like LuaTeX does
113/// (see [`parse_source_date_epoch`]).
114pub fn source_date_epoch() -> Option<i64> {
115    let v = std::env::var_os("SOURCE_DATE_EPOCH")?;
116    parse_source_date_epoch(&v.to_string_lossy())
117}
118
119/// Interprets a `SOURCE_DATE_EPOCH` value; `None` means "use the clock".
120///
121/// Observed behaviour of LuaTeX 1.22 (the value is honoured whether or not
122/// `FORCE_SOURCE_DATE` is set):
123///
124/// * an empty value is ignored;
125/// * leading white space and a sign are accepted, then decimal digits that
126///   must reach the end of the value; anything else (`abc`, `5x`, `5 `,
127///   `0x10`, blanks only) and values that overflow 64 bits count as 0;
128/// * a minus sign negates modulo 2⁶⁴; the result is then truncated to
129///   32 bits, and negative results are ignored (`-5`, `4294967295`), so
130///   `99999999999` means 1215752191 and `4294967301` means 5.
131pub fn parse_source_date_epoch(v: &str) -> Option<i64> {
132    if v.is_empty() {
133        return None;
134    }
135    let s = v.trim_start_matches([' ', '\t', '\n', '\x0b', '\x0c', '\r']);
136    let (neg, digits) = match s.as_bytes().first() {
137        Some(b'-') => (true, &s[1..]),
138        Some(b'+') => (false, &s[1..]),
139        _ => (false, s),
140    };
141    let value = if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
142        digits.parse::<u64>().unwrap_or(0)
143    } else {
144        0
145    };
146    let value = if neg { value.wrapping_neg() } else { value };
147    let t = value as u32 as i32;
148    (t >= 0).then_some(i64::from(t))
149}
150
151/// Returns the local UTC offset (seconds east of UTC) at Unix time `t`.
152pub fn local_offset(t: i64) -> i64 {
153    match std::env::var("TZ") {
154        Ok(tz) => offset_for_tz(&tz, t).unwrap_or(0),
155        Err(_) => std::fs::read("/etc/localtime")
156            .ok()
157            .and_then(|d| tzif_offset(&d, t))
158            .unwrap_or(0),
159    }
160}
161
162/// The offset at `t` for a `TZ` value.
163pub fn offset_for_tz(tz: &str, t: i64) -> Option<i64> {
164    let tz = tz.strip_prefix(':').unwrap_or(tz);
165    if tz.is_empty() {
166        return Some(0);
167    }
168    let path = if tz.starts_with('/') {
169        Some(std::path::PathBuf::from(tz))
170    } else if !tz.contains("..") {
171        let dir = std::env::var_os("TZDIR").unwrap_or_else(|| ZONEINFO.into());
172        Some(std::path::Path::new(&dir).join(tz))
173    } else {
174        None
175    };
176    if let Some(data) = path.and_then(|p| std::fs::read(p).ok())
177        && let Some(off) = tzif_offset(&data, t)
178    {
179        return Some(off);
180    }
181    PosixTz::parse(tz).map(|r| r.offset_at(t))
182}
183
184fn be32(d: &[u8], p: usize) -> Option<i64> {
185    d.get(p..p + 4)
186        .map(|b| i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as i64)
187}
188
189fn be64(d: &[u8], p: usize) -> Option<i64> {
190    d.get(p..p + 8).map(|b| {
191        let mut a = [0u8; 8];
192        a.copy_from_slice(b);
193        i64::from_be_bytes(a)
194    })
195}
196
197/// The UTC offset at `t` according to a TZif file.
198pub fn tzif_offset(d: &[u8], t: i64) -> Option<i64> {
199    if d.get(0..4)? != b"TZif" {
200        return None;
201    }
202    let version = *d.get(4)?;
203    let counts = |base: usize| -> Option<[usize; 6]> {
204        let mut c = [0usize; 6];
205        for (i, v) in c.iter_mut().enumerate() {
206            *v = usize::try_from(be32(d, base + 20 + 4 * i)?).ok()?;
207        }
208        Some(c)
209    };
210    let [isut, isstd, leap, timecnt, typecnt, charcnt] = counts(0)?;
211    let v1_len = 44 + timecnt * 5 + typecnt * 6 + charcnt + leap * 8 + isstd + isut;
212    // Use the 64-bit data block of version 2+ files.
213    let (base, tsize, c) = if version >= b'2' {
214        (v1_len, 8, counts(v1_len)?)
215    } else {
216        (0, 4, counts(0)?)
217    };
218    let [isut, isstd, leap, timecnt, typecnt, charcnt] = c;
219    if typecnt == 0 {
220        return None;
221    }
222    let times = base + 44;
223    let idx = times + timecnt * tsize;
224    let types = idx + timecnt;
225    let read_t = |i: usize| {
226        if tsize == 8 {
227            be64(d, times + 8 * i)
228        } else {
229            be32(d, times + 4 * i)
230        }
231    };
232    let ttinfo = |i: usize| -> Option<(i64, bool)> {
233        let p = types + 6 * i;
234        Some((be32(d, p)?, *d.get(p + 4)? != 0))
235    };
236    let footer = types + typecnt * 6 + charcnt + leap * (tsize + 4) + isstd + isut;
237
238    // Find the last transition at or before t.
239    let mut last: Option<usize> = None;
240    for i in 0..timecnt {
241        if read_t(i)? <= t {
242            last = Some(i);
243        } else {
244            break;
245        }
246    }
247    match last {
248        Some(i) if i + 1 == timecnt && version >= b'2' => {
249            // After the last transition, the footer rule applies.
250            if let Some(rule) = footer_rule(d, footer) {
251                return Some(rule.offset_at(t));
252            }
253            let ty = *d.get(idx + i)? as usize;
254            Some(ttinfo(ty)?.0)
255        }
256        Some(i) => {
257            let ty = *d.get(idx + i)? as usize;
258            Some(ttinfo(ty)?.0)
259        }
260        None => {
261            if timecnt == 0
262                && version >= b'2'
263                && let Some(rule) = footer_rule(d, footer)
264            {
265                return Some(rule.offset_at(t));
266            }
267            // Before the first transition: time type 0 (RFC 8536, 3.2).
268            Some(ttinfo(0)?.0)
269        }
270    }
271}
272
273fn footer_rule(d: &[u8], at: usize) -> Option<PosixTz> {
274    let rest = d.get(at..)?;
275    let rest = rest.strip_prefix(b"\n")?;
276    let end = rest.iter().position(|&b| b == b'\n')?;
277    let s = std::str::from_utf8(&rest[..end]).ok()?;
278    if s.is_empty() {
279        return None;
280    }
281    PosixTz::parse(s)
282}
283
284/// A DST transition rule date.
285#[derive(Clone, Copy, Debug, PartialEq)]
286enum RuleDate {
287    /// `Jn`: day 1–365, February 29 never counted.
288    Julian1(i64),
289    /// `n`: day 0–365, counting February 29.
290    Julian0(i64),
291    /// `Mm.w.d`: day `d` (0 = Sunday) of week `w` (5 = last) of month `m`.
292    MonthWeekDay(i64, i64, i64),
293}
294
295/// A parsed POSIX TZ string.
296#[derive(Clone, Copy, Debug, PartialEq)]
297struct PosixTz {
298    /// Standard offset, seconds east of UTC.
299    std: i64,
300    /// DST offset and rules: (offset, start, start time, end, end time).
301    dst: Option<(i64, RuleDate, i64, RuleDate, i64)>,
302}
303
304struct Cursor<'a> {
305    s: &'a [u8],
306    p: usize,
307}
308
309impl Cursor<'_> {
310    fn peek(&self) -> Option<u8> {
311        self.s.get(self.p).copied()
312    }
313
314    fn eat(&mut self, c: u8) -> bool {
315        if self.peek() == Some(c) {
316            self.p += 1;
317            true
318        } else {
319            false
320        }
321    }
322
323    fn name(&mut self) -> Option<()> {
324        if self.eat(b'<') {
325            while self.peek()? != b'>' {
326                self.p += 1;
327            }
328            self.p += 1;
329            return Some(());
330        }
331        let start = self.p;
332        while self.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
333            self.p += 1;
334        }
335        (self.p - start >= 3).then_some(())
336    }
337
338    fn number(&mut self) -> Option<i64> {
339        let start = self.p;
340        while self.peek().is_some_and(|c| c.is_ascii_digit()) {
341            self.p += 1;
342        }
343        std::str::from_utf8(&self.s[start..self.p])
344            .ok()?
345            .parse()
346            .ok()
347    }
348
349    /// `[+-]hh[:mm[:ss]]` in seconds.
350    fn hms(&mut self) -> Option<i64> {
351        let neg = if self.eat(b'-') {
352            true
353        } else {
354            self.eat(b'+');
355            false
356        };
357        let mut v = self.number()? * 3600;
358        if self.eat(b':') {
359            v += self.number()? * 60;
360            if self.eat(b':') {
361                v += self.number()?;
362            }
363        }
364        Some(if neg { -v } else { v })
365    }
366
367    fn date(&mut self) -> Option<RuleDate> {
368        if self.eat(b'J') {
369            Some(RuleDate::Julian1(self.number()?))
370        } else if self.eat(b'M') {
371            let m = self.number()?;
372            self.eat(b'.').then_some(())?;
373            let w = self.number()?;
374            self.eat(b'.').then_some(())?;
375            let d = self.number()?;
376            Some(RuleDate::MonthWeekDay(m, w, d))
377        } else {
378            Some(RuleDate::Julian0(self.number()?))
379        }
380    }
381}
382
383impl PosixTz {
384    fn parse(s: &str) -> Option<PosixTz> {
385        let mut c = Cursor {
386            s: s.as_bytes(),
387            p: 0,
388        };
389        c.name()?;
390        let std = -c.hms()?;
391        if c.peek().is_none() {
392            return Some(PosixTz { std, dst: None });
393        }
394        c.name()?;
395        let dst_off = if matches!(c.peek(), Some(b',') | None) {
396            std + 3600
397        } else {
398            -c.hms()?
399        };
400        let (start, st, end, et) = if c.eat(b',') {
401            let start = c.date()?;
402            let st = if c.eat(b'/') { c.hms()? } else { 7200 };
403            c.eat(b',').then_some(())?;
404            let end = c.date()?;
405            let et = if c.eat(b'/') { c.hms()? } else { 7200 };
406            (start, st, end, et)
407        } else {
408            // POSIX leaves the default rules implementation-defined; use the
409            // US rules like glibc.
410            (
411                RuleDate::MonthWeekDay(3, 2, 0),
412                7200,
413                RuleDate::MonthWeekDay(11, 1, 0),
414                7200,
415            )
416        };
417        Some(PosixTz {
418            std,
419            dst: Some((dst_off, start, st, end, et)),
420        })
421    }
422
423    fn offset_at(&self, t: i64) -> i64 {
424        let Some((dst, start, st, end, et)) = self.dst else {
425            return self.std;
426        };
427        let year = civil_from_days((t + self.std).div_euclid(86400)).0;
428        // Transition instants in UTC: the start is given in standard local
429        // time, the end in daylight local time.
430        let s = days_from_civil(year, 1, 1) * 86400 + rule_day_offset(start, year) * 86400 + st
431            - self.std;
432        let e = days_from_civil(year, 1, 1) * 86400 + rule_day_offset(end, year) * 86400 + et - dst;
433        let in_dst = if s < e {
434            t >= s && t < e
435        } else {
436            !(t >= e && t < s)
437        };
438        if in_dst { dst } else { self.std }
439    }
440}
441
442fn is_leap(y: i64) -> bool {
443    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
444}
445
446/// Days since January 1 of `year` for a rule date.
447fn rule_day_offset(r: RuleDate, year: i64) -> i64 {
448    match r {
449        RuleDate::Julian1(n) => {
450            let n = n.clamp(1, 365);
451            if is_leap(year) && n >= 60 { n } else { n - 1 }
452        }
453        RuleDate::Julian0(n) => n,
454        RuleDate::MonthWeekDay(m, w, d) => {
455            let m = m.clamp(1, 12);
456            let first = days_from_civil(year, m, 1);
457            let jan1 = days_from_civil(year, 1, 1);
458            // 1970-01-01 was a Thursday (4).
459            let wd_first = (first + 4).rem_euclid(7);
460            let mut day = first + (d - wd_first).rem_euclid(7) + 7 * (w.clamp(1, 5) - 1);
461            let (ny, nm) = if m == 12 {
462                (year + 1, 1)
463            } else {
464                (year, m + 1)
465            };
466            let next = days_from_civil(ny, nm, 1);
467            while day >= next {
468                day -= 7;
469            }
470            day - jan1
471        }
472    }
473}
474
475/// Days since 1970-01-01 of a proleptic Gregorian date.
476pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
477    let y = if m <= 2 { y - 1 } else { y };
478    let era = y.div_euclid(400);
479    let yoe = y - era * 400;
480    let mp = (m + 9) % 12;
481    let doy = (153 * mp + 2) / 5 + d - 1;
482    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
483    era * 146097 + doe - 719468
484}
485
486/// The proleptic Gregorian date (year, month, day) of a day number.
487pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
488    let z = z + 719468;
489    let era = z.div_euclid(146097);
490    let doe = z - era * 146097;
491    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
492    let y = yoe + era * 400;
493    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
494    let mp = (5 * doy + 2) / 153;
495    let d = doy - (153 * mp + 2) / 5 + 1;
496    let m = if mp < 10 { mp + 3 } else { mp - 9 };
497    (if m <= 2 { y + 1 } else { y }, m, d)
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn civil_round_trip() {
506        assert_eq!(days_from_civil(1970, 1, 1), 0);
507        assert_eq!(civil_from_days(0), (1970, 1, 1));
508        assert_eq!(civil_from_days(-1), (1969, 12, 31));
509        assert_eq!(days_from_civil(2000, 3, 1), 11017);
510        for z in [-800000, -1, 0, 59, 10957, 20000, 2932896] {
511            let (y, m, d) = civil_from_days(z);
512            assert_eq!(days_from_civil(y, m, d), z);
513        }
514    }
515
516    #[test]
517    fn posix_rules() {
518        let cet = PosixTz::parse("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
519        assert_eq!(cet.offset_at(0), 3600);
520        // 2026-07-01 12:00 UTC
521        assert_eq!(
522            cet.offset_at(days_from_civil(2026, 7, 1) * 86400 + 43200),
523            7200
524        );
525        // 2026-03-29 00:59:59 UTC is still CET, 01:00 UTC is CEST.
526        let t = days_from_civil(2026, 3, 29) * 86400 + 3600;
527        assert_eq!(cet.offset_at(t - 1), 3600);
528        assert_eq!(cet.offset_at(t), 7200);
529        let ny = PosixTz::parse("EST5EDT,M3.2.0,M11.1.0").unwrap();
530        assert_eq!(ny.offset_at(0), -18000);
531        let india = PosixTz::parse("IST-5:30").unwrap();
532        assert_eq!(india.offset_at(0), 19800);
533        let quoted = PosixTz::parse("<+0330>-3:30").unwrap();
534        assert_eq!(quoted.offset_at(0), 12600);
535        assert!(PosixTz::parse("X").is_none());
536    }
537
538    #[test]
539    fn zone_files_if_present() {
540        // These match lualatex's dates for SOURCE_DATE_EPOCH=0.
541        if std::path::Path::new("/usr/share/zoneinfo/America/New_York").exists() {
542            assert_eq!(offset_for_tz("America/New_York", 0), Some(-18000));
543            assert_eq!(offset_for_tz("Asia/Kolkata", 0), Some(19800));
544            assert_eq!(offset_for_tz("Europe/Paris", 0), Some(3600));
545            assert_eq!(offset_for_tz("Europe/Paris", 1_790_000_000), Some(7200));
546        }
547        assert_eq!(offset_for_tz("UTC0", 0), Some(0));
548        assert_eq!(offset_for_tz("", 0), Some(0));
549    }
550
551    #[test]
552    fn source_date_epoch_like_luatex() {
553        // Values observed with LuaTeX 1.22 (\time, \day, … and the banner).
554        let cases: &[(&str, Option<i64>)] = &[
555            ("", None),
556            ("0", Some(0)),
557            ("abc", Some(0)),
558            ("-5", None),
559            (" 5", Some(5)),
560            ("5x", Some(0)),
561            ("5 ", Some(0)),
562            ("  ", Some(0)),
563            ("+7", Some(7)),
564            ("-0", Some(0)),
565            ("0x10", Some(0)),
566            ("99999999999", Some(1_215_752_191)),
567            ("18446744073709551616", Some(0)),
568            ("4294967295", None),
569            ("2147483648", None),
570            ("2147483647", Some(2_147_483_647)),
571            ("4294967296", Some(0)),
572            ("4294967301", Some(5)),
573            ("-4294967291", Some(5)),
574        ];
575        for &(v, want) in cases {
576            assert_eq!(parse_source_date_epoch(v), want, "{v:?}");
577        }
578    }
579
580    #[test]
581    fn dates_like_luatex() {
582        let d = |unix, offset| StartTime { unix, offset }.pdf_date();
583        assert_eq!(d(0, 0), "D:19700101000000Z");
584        assert_eq!(d(0, 3600), "D:19700101010000+01'00'");
585        assert_eq!(d(0, -18000), "D:19691231190000-05'00'");
586        assert_eq!(d(0, 19800), "D:19700101053000+05'30'");
587        assert_eq!(d(1_790_000_000, 7200), "D:20260921161320+02'00'");
588        assert_eq!(d(1_790_000_000, 0), "D:20260921141320Z");
589        let c = StartTime {
590            unix: 1_790_000_000,
591            offset: 7200,
592        }
593        .civil();
594        assert_eq!(
595            (c.year, c.month, c.day, c.hour, c.minute, c.second),
596            (2026, 9, 21, 16, 13, 20)
597        );
598        assert_eq!(
599            StartTime::new(Some(5), true),
600            StartTime { unix: 5, offset: 0 }
601        );
602    }
603
604    #[test]
605    fn garbage_is_not_a_panic() {
606        assert_eq!(tzif_offset(b"TZif2", 0), None);
607        assert_eq!(tzif_offset(b"nope", 0), None);
608        let mut d = b"TZif2".to_vec();
609        d.resize(44, 0);
610        d[39] = 1; // typecnt = 1 but no data
611        assert_eq!(tzif_offset(&d, 0), None);
612    }
613}