Skip to main content

typst_pdf/
metadata.rs

1use krilla::metadata::{Metadata, TextDirection};
2use typst_library::foundations::{Datetime, Smart};
3use typst_library::layout::Dir;
4use typst_library::model::Document;
5use typst_library::text::Locale;
6
7use crate::convert::GlobalContext;
8
9pub(crate) fn build_metadata(gc: &GlobalContext, doc_lang: Option<Locale>) -> Metadata {
10    // Always write a language, PDF/UA-1 implicitly requires a document language
11    // so the metadata and outline entries have an applicable language.
12    let lang = doc_lang.unwrap_or(Locale::DEFAULT);
13
14    let dir = if lang.lang.dir() == Dir::RTL {
15        TextDirection::RightToLeft
16    } else {
17        TextDirection::LeftToRight
18    };
19
20    let mut metadata = Metadata::new()
21        .keywords(gc.document.info().keywords.iter().map(Into::into).collect())
22        .authors(gc.document.info().author.iter().map(Into::into).collect())
23        .language(lang.rfc_3066().to_string());
24
25    if let Some(creator) = gc
26        .options
27        .creator
28        .clone()
29        .unwrap_or_else(|| Some(format!("Typst {}", typst_utils::version().raw())))
30    {
31        metadata = metadata.creator(creator);
32    }
33
34    if let Some(title) = &gc.document.info().title {
35        metadata = metadata.title(title.to_string());
36    }
37
38    if let Some(description) = &gc.document.info().description {
39        metadata = metadata.description(description.to_string());
40    }
41
42    if let Some(ident) = gc.options.ident.as_ref().custom() {
43        metadata = metadata.document_id(ident.to_string());
44    }
45
46    if let Some(date) = creation_date(gc) {
47        metadata = metadata.creation_date(date);
48    }
49
50    metadata = metadata.text_direction(dir);
51
52    metadata
53}
54
55/// (1) If the `document.date` is set to specific `datetime` or `none`, use it.
56/// (2) If the `document.date` is set to `auto` or not set, try to use the
57///     date from the options.
58/// (3) Otherwise, we don't write date metadata.
59pub fn creation_date(gc: &GlobalContext) -> Option<krilla::metadata::DateTime> {
60    let (datetime, tz) = match (gc.document.info().date, gc.options.timestamp) {
61        (Smart::Custom(Some(date)), _) => (date, None),
62        (Smart::Auto, Some(timestamp)) => (timestamp.datetime, Some(timestamp.timezone)),
63        _ => return None,
64    };
65
66    let year = datetime.year().filter(|&y| y >= 0)? as u16;
67
68    let mut kd = krilla::metadata::DateTime::new(year);
69
70    if let Some(month) = datetime.month() {
71        kd = kd.month(month);
72    }
73
74    if let Some(day) = datetime.day() {
75        kd = kd.day(day);
76    }
77
78    if let Some(h) = datetime.hour() {
79        kd = kd.hour(h);
80    }
81
82    if let Some(m) = datetime.minute() {
83        kd = kd.minute(m);
84    }
85
86    if let Some(s) = datetime.second() {
87        kd = kd.second(s);
88    }
89
90    match tz {
91        Some(Timezone::UTC) => kd = kd.utc_offset_hour(0).utc_offset_minute(0),
92        Some(Timezone::Local { hour_offset, minute_offset }) => {
93            kd = kd.utc_offset_hour(hour_offset).utc_offset_minute(minute_offset)
94        }
95        None => {}
96    }
97
98    Some(kd)
99}
100
101/// A timestamp with timezone information.
102#[derive(Debug, Copy, Clone, Hash)]
103pub struct Timestamp {
104    /// The datetime of the timestamp.
105    pub(crate) datetime: Datetime,
106    /// The timezone of the timestamp.
107    pub(crate) timezone: Timezone,
108}
109
110impl Timestamp {
111    /// Create a new timestamp with a given datetime and UTC suffix.
112    pub fn new_utc(datetime: Datetime) -> Self {
113        Self { datetime, timezone: Timezone::UTC }
114    }
115
116    /// Create a new timestamp with a given datetime, and a local timezone offset.
117    pub fn new_local(datetime: Datetime, whole_minute_offset: i32) -> Option<Self> {
118        let hour_offset = (whole_minute_offset / 60).try_into().ok()?;
119        // Note: the `%` operator in Rust is the remainder operator, not the
120        // modulo operator. The remainder operator can return negative results.
121        // We can simply apply `abs` here because we assume the `minute_offset`
122        // will have the same sign as `hour_offset`.
123        let minute_offset = (whole_minute_offset % 60).abs().try_into().ok()?;
124        match (hour_offset, minute_offset) {
125            // Only accept valid timezone offsets with `-23 <= hours <= 23`,
126            // and `0 <= minutes <= 59`.
127            (-23..=23, 0..=59) => Some(Self {
128                datetime,
129                timezone: Timezone::Local { hour_offset, minute_offset },
130            }),
131            _ => None,
132        }
133    }
134}
135
136/// A timezone.
137#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
138pub enum Timezone {
139    /// The UTC timezone.
140    UTC,
141    /// The local timezone offset from UTC. And the `minute_offset` will have
142    /// same sign as `hour_offset`.
143    Local { hour_offset: i8, minute_offset: u8 },
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_timestamp_new_local() {
152        let dummy_datetime = Datetime::from_ymd_hms(2024, 12, 17, 10, 10, 10).unwrap();
153        let test = |whole_minute_offset, expect_timezone| {
154            assert_eq!(
155                Timestamp::new_local(dummy_datetime, whole_minute_offset)
156                    .unwrap()
157                    .timezone,
158                expect_timezone
159            );
160        };
161
162        // Valid timezone offsets
163        test(0, Timezone::Local { hour_offset: 0, minute_offset: 0 });
164        test(480, Timezone::Local { hour_offset: 8, minute_offset: 0 });
165        test(-480, Timezone::Local { hour_offset: -8, minute_offset: 0 });
166        test(330, Timezone::Local { hour_offset: 5, minute_offset: 30 });
167        test(-210, Timezone::Local { hour_offset: -3, minute_offset: 30 });
168        test(-720, Timezone::Local { hour_offset: -12, minute_offset: 0 }); // AoE
169
170        // Corner cases
171        test(315, Timezone::Local { hour_offset: 5, minute_offset: 15 });
172        test(-225, Timezone::Local { hour_offset: -3, minute_offset: 45 });
173        test(1439, Timezone::Local { hour_offset: 23, minute_offset: 59 });
174        test(-1439, Timezone::Local { hour_offset: -23, minute_offset: 59 });
175
176        // Invalid timezone offsets
177        assert!(Timestamp::new_local(dummy_datetime, 1440).is_none());
178        assert!(Timestamp::new_local(dummy_datetime, -1440).is_none());
179        assert!(Timestamp::new_local(dummy_datetime, i32::MAX).is_none());
180        assert!(Timestamp::new_local(dummy_datetime, i32::MIN).is_none());
181    }
182}