Skip to main content

pdfluent_lopdf/
datetime.rs

1use super::Object;
2
3#[cfg(feature = "chrono")]
4mod chrono_impl {
5    use crate::{Object, datetime::convert_utc_offset};
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::{Object, datetime::convert_utc_offset};
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(|_| {
87                    DateTime::strptime("%Y%m%d%H%M%SZ", &value.0).and_then(|dt| dt.in_tz("UTC"))
88                })
89                .or_else(|_| Zoned::strptime("%Y%m%d%H%M%#z", &value.0))
90                .or_else(|_| {
91                    DateTime::strptime("%Y%m%d%H%MZ", &value.0).and_then(|dt| dt.in_tz("UTC"))
92                })
93                .or_else(|_| {
94                    Date::strptime("%Y%m%d", &value.0).and_then(|dt| dt.at(0, 0, 0, 0).in_tz("GMT"))
95                })
96        }
97    }
98}
99
100#[cfg(feature = "time")]
101mod time_impl {
102    use crate::Object;
103    use time::{OffsetDateTime, Time, format_description::FormatItem};
104
105    impl From<Time> for Object {
106        fn from(date: Time) -> Self {
107            // can only fail if the TIME_FMT_ENCODE_STR would be invalid
108            Object::string_literal(
109                format!(
110                    "D:{}",
111                    date.format(&FormatItem::Literal("%Y%m%d%H%M%SZ".as_bytes()))
112                        .unwrap()
113                )
114                .into_bytes(),
115            )
116        }
117    }
118
119    impl From<OffsetDateTime> for Object {
120        fn from(date: OffsetDateTime) -> Self {
121            Object::string_literal({
122                // D:%Y%m%d%H%M%S:%z'
123                let format = time::format_description::parse(
124                    "D:[year][month][day][hour][minute][second][offset_hour sign:mandatory]'[offset_minute]'",
125                )
126                .unwrap();
127                date.format(&format).unwrap()
128            })
129        }
130    }
131
132    /// WARNING: `tm_wday` (weekday), `tm_yday` (day index in year), `tm_isdst`
133    /// (daylight saving time) and `tm_nsec` (nanoseconds of the date from 1970)
134    /// are set to 0 since they aren't available in the PDF time format. They could,
135    /// however, be calculated manually
136    impl TryFrom<super::DateTime> for OffsetDateTime {
137        type Error = time::Error;
138
139        fn try_from(value: super::DateTime) -> Result<OffsetDateTime, Self::Error> {
140            let format = time::format_description::parse(
141                "[year][month][day][hour][minute][second][offset_hour sign:mandatory][offset_minute]",
142            )
143            .unwrap();
144
145            Ok(OffsetDateTime::parse(&value.0, &format)?)
146        }
147    }
148}
149
150// Find the last `:` and turn it into an `'` to account for PDF weirdness
151#[allow(dead_code)]
152fn convert_utc_offset(bytes: &mut [u8]) {
153    let mut index = bytes.len();
154    while let Some(last) = bytes[..index].last_mut() {
155        if *last == b':' {
156            *last = b'\'';
157            break;
158        }
159        index -= 1;
160    }
161}
162
163#[derive(Clone, Debug)]
164pub struct DateTime(String);
165
166impl Object {
167    // Parses the `D`, `:` and `\` out of a `Object::String` to parse the date time
168    fn datetime_string(&self) -> Option<String> {
169        if let Object::String(bytes, _) = self {
170            String::from_utf8(
171                bytes
172                    .iter()
173                    .filter(|b| ![b'D', b':', b'\''].contains(b))
174                    .cloned()
175                    .collect(),
176            )
177            .ok()
178        } else {
179            None
180        }
181    }
182
183    pub fn as_datetime(&self) -> Option<DateTime> {
184        self.datetime_string().map(DateTime)
185    }
186}
187
188#[cfg(feature = "chrono")]
189#[test]
190fn parse_datetime_local() {
191    use chrono::prelude::*;
192
193    let time = Local::now().with_nanosecond(0).unwrap();
194    let text: Object = time.into();
195    let time2: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
196    assert_eq!(time2, Some(time));
197}
198
199#[cfg(feature = "chrono")]
200#[test]
201fn parse_datetime_utc() {
202    use chrono::prelude::*;
203
204    let time = Utc::now().with_nanosecond(0).unwrap();
205    let text: Object = time.into();
206    let time2: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
207    assert_eq!(time2, Some(time.with_timezone(&Local)));
208}
209
210#[cfg(feature = "jiff")]
211#[test]
212fn parse_zoned() {
213    use jiff::Zoned;
214
215    let time = Zoned::now().with().subsec_nanosecond(0).build().unwrap();
216    let text: Object = time.clone().into();
217    let time2: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
218    assert_eq!(time2, Some(time));
219}
220
221#[cfg(feature = "jiff")]
222#[test]
223fn parse_timestamp() {
224    use jiff::Zoned;
225
226    let time = Zoned::now().with().subsec_nanosecond(0).build().unwrap();
227    let text: Object = time.timestamp().into();
228    let time2: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
229    assert_eq!(time2, Some(time));
230}
231
232#[cfg(feature = "chrono")]
233#[test]
234fn parse_datetime_seconds_missing_chrono() {
235    use chrono::prelude::*;
236
237    // this is the example from the PDF reference, version 1.7, chapter 3.8.3
238    let text = Object::string_literal("D:199812231952-08'00'");
239    let dt: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
240    assert!(dt.is_some());
241}
242
243#[cfg(feature = "chrono")]
244#[test]
245fn parse_datetime_time_missing_chrono() {
246    use chrono::prelude::*;
247
248    let text = Object::string_literal("D:20040229");
249    let dt: Option<DateTime<Local>> = text.as_datetime().and_then(|dt| dt.try_into().ok());
250    assert!(dt.is_some());
251}
252
253#[cfg(feature = "jiff")]
254#[test]
255fn parse_datetime_seconds_missing_jiff() {
256    use jiff::Zoned;
257
258    // this is the example from the PDF reference, version 1.7, chapter 3.8.3
259    let text = Object::string_literal("D:199812231952-08'00'");
260    let dt: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
261    assert!(dt.is_some());
262}
263
264#[cfg(feature = "jiff")]
265#[test]
266fn parse_datetime_time_missing_jiff() {
267    use jiff::Zoned;
268
269    let text = Object::string_literal("D:20040229");
270    let dt: Option<Zoned> = text.as_datetime().and_then(|dt| dt.try_into().ok());
271    assert!(dt.is_some());
272}
273
274#[cfg(feature = "time")]
275#[test]
276fn parse_datetime() {
277    use time::OffsetDateTime;
278
279    let time = OffsetDateTime::now_utc();
280
281    let text: Object = time.into();
282    let time2: OffsetDateTime = text.as_datetime().unwrap().try_into().unwrap();
283
284    assert_eq!(time2.date(), time.date());
285
286    // Ignore nanoseconds
287    // - not important in the date parsing
288    assert_eq!(time2.time().hour(), time.time().hour());
289    assert_eq!(time2.time().minute(), time.time().minute());
290    assert_eq!(time2.time().second(), time.time().second());
291}