lopdf/
datetime.rs

1use super::Object;
2
3#[cfg(feature = "chrono")]
4mod chrono_impl {
5    use crate::{datetime::convert_utc_offset, Object};
6    use chrono::prelude::*;
7
8    impl From<DateTime<Local>> for Object {
9        fn from(date: DateTime<Local>) -> Self {
10            let mut timezone_str = date.format("D:%Y%m%d%H%M%S%:z'").to_string().into_bytes();
11            convert_utc_offset(&mut timezone_str);
12            Object::string_literal(timezone_str)
13        }
14    }
15
16    impl From<DateTime<Utc>> for Object {
17        fn from(date: DateTime<Utc>) -> Self {
18            Object::string_literal(date.format("D:%Y%m%d%H%M%SZ").to_string())
19        }
20    }
21
22    impl TryFrom<super::DateTime> for DateTime<Local> {
23        type Error = chrono::format::ParseError;
24
25        fn try_from(value: super::DateTime) -> Result<DateTime<Local>, Self::Error> {
26            let from_date = |date: NaiveDate| {
27                FixedOffset::east_opt(0)
28                    .unwrap()
29                    .from_utc_datetime(&date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()))
30            };
31
32            DateTime::parse_from_str(&value.0, "%Y%m%d%H%M%S%#z")
33                .or_else(|_| DateTime::parse_from_str(&value.0, "%Y%m%d%H%M%#z"))
34                .or_else(|_| NaiveDate::parse_from_str(&value.0, "%Y%m%d").map(from_date))
35                .map(|date| date.with_timezone(&Local))
36        }
37    }
38}
39
40#[cfg(feature = "jiff")]
41mod jiff_impl {
42    use crate::{datetime::convert_utc_offset, Object};
43    use jiff::{Timestamp, Zoned};
44
45    impl From<Zoned> for Object {
46        fn from(date: Zoned) -> Self {
47            let mut timezone_str = date.strftime("D:%Y%m%d%H%M%S%:z'").to_string().into_bytes();
48            convert_utc_offset(&mut timezone_str);
49            Object::string_literal(timezone_str)
50        }
51    }
52
53    impl From<Timestamp> for Object {
54        fn from(date: Timestamp) -> Self {
55            Object::string_literal(date.strftime("D:%Y%m%d%H%M%SZ").to_string())
56        }
57    }
58
59    impl TryFrom<super::DateTime> for Zoned {
60        type Error = jiff::Error;
61
62        fn try_from(value: super::DateTime) -> Result<Self, Self::Error> {
63            use jiff::civil::{Date, DateTime};
64
65            // We attempt to parse different date time formats based on Section 7.9.4 "Dates" in
66            // PDF 32000-1:2008 here.
67            //
68            // "A PLUS SIGN as the value of the O field signifies that the local time is later than
69            // UT, a HYPHEN-MINUS signifies that local time is earlier than UT, and the LATIN
70            // CAPITAL Z signifies that local time is equal to UT. If no UT information is
71            // specified, the relationship of the specified time to UT shall be considered GMT."
72            //
73            // 1. Try parsing the full date and time with the `%#z` specifier to parse the timezone
74            //    as a `Zoned` object.
75            // 2. Try parsing the full date and time with the 'Z' suffix as a `DateTime` interpreted
76            //    to be in the UTC timezone.
77            // 3. Try parsing the date and time without the seconds specified with the `%#z`
78            //    specifier to parse the timezone as a `Zoned` object.
79            // 4. Try parsing the date and time without the seconds specified with the 'Z' as a
80            //    `DateTime` interpreted to be in the UTC timezone.
81            // 5. Try parsing the date with no time as a `Date` interpreted to be in the GMT
82            //    timezone.
83            //
84            // In all cases we return a `Zoned` object here to preserve the timezone.
85            Zoned::strptime("%Y%m%d%H%M%S%#z", &value.0)
86                .or_else(|_| DateTime::strptime("%Y%m%d%H%M%SZ", &value.0).and_then(|dt| dt.in_tz("UTC")))
87                .or_else(|_| Zoned::strptime("%Y%m%d%H%M%#z", &value.0))
88                .or_else(|_| DateTime::strptime("%Y%m%d%H%MZ", &value.0).and_then(|dt| dt.in_tz("UTC")))
89                .or_else(|_| Date::strptime("%Y%m%d", &value.0).and_then(|dt| dt.at(0, 0, 0, 0).in_tz("GMT")))
90        }
91    }
92}
93
94#[cfg(feature = "time")]
95mod time_impl {
96    use crate::Object;
97    use time::{format_description::FormatItem, OffsetDateTime, Time};
98
99    impl From<Time> for Object {
100        fn from(date: Time) -> Self {
101            // can only fail if the TIME_FMT_ENCODE_STR would be invalid
102            Object::string_literal(
103                format!(
104                    "D:{}",
105                    date.format(&FormatItem::Literal("%Y%m%d%H%M%SZ".as_bytes())).unwrap()
106                )
107                .into_bytes(),
108            )
109        }
110    }
111
112    impl From<OffsetDateTime> for Object {
113        fn from(date: OffsetDateTime) -> Self {
114            Object::string_literal({
115                // D:%Y%m%d%H%M%S:%z'
116                let format = time::format_description::parse(
117                    "D:[year][month][day][hour][minute][second][offset_hour sign:mandatory]'[offset_minute]'",
118                )
119                .unwrap();
120                date.format(&format).unwrap()
121            })
122        }
123    }
124
125    /// WARNING: `tm_wday` (weekday), `tm_yday` (day index in year), `tm_isdst`
126    /// (daylight saving time) and `tm_nsec` (nanoseconds of the date from 1970)
127    /// are set to 0 since they aren't available in the PDF time format. They could,
128    /// however, be calculated manually
129    impl TryFrom<super::DateTime> for OffsetDateTime {
130        type Error = time::Error;
131
132        fn try_from(value: super::DateTime) -> Result<OffsetDateTime, Self::Error> {
133            let format = time::format_description::parse(
134                "[year][month][day][hour][minute][second][offset_hour sign:mandatory][offset_minute]",
135            )
136            .unwrap();
137
138            Ok(OffsetDateTime::parse(&value.0, &format)?)
139        }
140    }
141}
142
143// Find the last `:` and turn it into an `'` to account for PDF weirdness
144#[allow(dead_code)]
145fn convert_utc_offset(bytes: &mut [u8]) {
146    let mut index = bytes.len();
147    while let Some(last) = bytes[..index].last_mut() {
148        if *last == b':' {
149            *last = b'\'';
150            break;
151        }
152        index -= 1;
153    }
154}
155
156#[derive(Clone, Debug)]
157pub struct DateTime(String);
158
159impl Object {
160    // Parses the `D`, `:` and `\` out of a `Object::String` to parse the date time
161    fn datetime_string(&self) -> Option<String> {
162        if let Object::String(bytes, _) = self {
163            String::from_utf8(
164                bytes
165                    .iter()
166                    .filter(|b| ![b'D', b':', b'\''].contains(b))
167                    .cloned()
168                    .collect(),
169            )
170            .ok()
171        } else {
172            None
173        }
174    }
175
176    pub fn as_datetime(&self) -> Option<DateTime> {
177        self.datetime_string().map(DateTime)
178    }
179}
180
181#[cfg(feature = "chrono")]
182#[test]
183fn parse_datetime_local() {
184    use chrono::prelude::*;
185
186    let time = Local::now().with_nanosecond(0).unwrap();
187    let text: Object = time.into();
188    let time2: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
189    assert_eq!(time2, Some(time));
190}
191
192#[cfg(feature = "chrono")]
193#[test]
194fn parse_datetime_utc() {
195    use chrono::prelude::*;
196
197    let time = Utc::now().with_nanosecond(0).unwrap();
198    let text: Object = time.into();
199    let time2: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
200    assert_eq!(time2, Some(time.with_timezone(&Local)));
201}
202
203#[cfg(feature = "jiff")]
204#[test]
205fn parse_zoned() {
206    use jiff::Zoned;
207
208    let time = Zoned::now().with().subsec_nanosecond(0).build().unwrap();
209    let text: Object = time.clone().into();
210    let time2: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
211    assert_eq!(time2, Some(time));
212}
213
214#[cfg(feature = "jiff")]
215#[test]
216fn parse_timestamp() {
217    use jiff::Zoned;
218
219    let time = Zoned::now().with().subsec_nanosecond(0).build().unwrap();
220    let text: Object = time.timestamp().into();
221    let time2: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
222    assert_eq!(time2, Some(time));
223}
224
225#[cfg(feature = "chrono")]
226#[test]
227fn parse_datetime_seconds_missing_chrono() {
228    use chrono::prelude::*;
229
230    // this is the example from the PDF reference, version 1.7, chapter 3.8.3
231    let text = Object::string_literal("D:199812231952-08'00'");
232    let dt: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
233    assert!(dt.is_some());
234}
235
236#[cfg(feature = "chrono")]
237#[test]
238fn parse_datetime_time_missing_chrono() {
239    use chrono::prelude::*;
240
241    let text = Object::string_literal("D:20040229");
242    let dt: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
243    assert!(dt.is_some());
244}
245
246#[cfg(feature = "jiff")]
247#[test]
248fn parse_datetime_seconds_missing_jiff() {
249    use jiff::Zoned;
250
251    // this is the example from the PDF reference, version 1.7, chapter 3.8.3
252    let text = Object::string_literal("D:199812231952-08'00'");
253    let dt: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
254    assert!(dt.is_some());
255}
256
257#[cfg(feature = "jiff")]
258#[test]
259fn parse_datetime_time_missing_jiff() {
260    use jiff::Zoned;
261
262    let text = Object::string_literal("D:20040229");
263    let dt: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
264    assert!(dt.is_some());
265}
266
267#[cfg(feature = "time")]
268#[test]
269fn parse_datetime() {
270    use time::OffsetDateTime;
271
272    let time = OffsetDateTime::now_utc();
273
274    let text: Object = time.into();
275    let time2: OffsetDateTime = text.as_datetime().unwrap().try_into().unwrap();
276
277    assert_eq!(time2.date(), time.date());
278
279    // Ignore nanoseconds
280    // - not important in the date parsing
281    assert_eq!(time2.time().hour(), time.time().hour());
282    assert_eq!(time2.time().minute(), time.time().minute());
283    assert_eq!(time2.time().second(), time.time().second());
284}