tinymist_task/compute/
pdf.rs

1//! The computation for pdf export.
2
3use chrono::{Datelike, Timelike};
4use tinymist_std::time::LocalDatetime;
5use tinymist_world::args::PdfStandard;
6use typst::foundations::Datetime;
7pub use typst_pdf::PdfStandard as TypstPdfStandard;
8pub use typst_pdf::pdf;
9
10use typst_pdf::{PdfOptions, PdfStandards, Timestamp};
11
12use super::*;
13use crate::model::ExportPdfTask;
14
15/// The computation for pdf export.
16pub struct PdfExport;
17
18impl<F: CompilerFeat> ExportComputation<F, TypstPagedDocument> for PdfExport {
19    type Output = Bytes;
20    type Config = ExportPdfTask;
21
22    fn run(
23        _graph: &Arc<WorldComputeGraph<F>>,
24        doc: &Arc<TypstPagedDocument>,
25        config: &ExportPdfTask,
26    ) -> Result<Bytes> {
27        let options = pdf_options(
28            config.pages.as_deref(),
29            &config.pdf_standards,
30            config.no_pdf_tags,
31            config.creation_timestamp,
32        )?;
33
34        // log::info!("used options for pdf export: {options:?}");
35
36        // todo: Some(pdf_uri.as_str())
37        // todo: ident option
38        Ok(Bytes::new(typst_pdf::pdf(doc, &options)?))
39    }
40}
41
42/// Creates PDF options from shared project export arguments.
43pub fn pdf_options(
44    pages: Option<&[Pages]>,
45    pdf_standards: &[PdfStandard],
46    no_pdf_tags: bool,
47    creation_timestamp: Option<i64>,
48) -> Result<PdfOptions> {
49    // Match Typst CLI semantics: explicit timestamps stay in UTC for
50    // reproducible builds, while the default records local wall time and its
51    // UTC offset when the environment provides one.
52    let timestamp = match creation_timestamp {
53        Some(timestamp) => explicit_timestamp(timestamp)?,
54        None => tinymist_std::time::local_now().and_then(environment_timestamp),
55    };
56
57    let standards = PdfStandards::new(
58        &pdf_standards
59            .iter()
60            .map(|standard| match standard {
61                PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4,
62                PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5,
63                PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6,
64                PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7,
65                PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0,
66                PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b,
67                PdfStandard::A_1a => typst_pdf::PdfStandard::A_1a,
68                PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b,
69                PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u,
70                PdfStandard::A_2a => typst_pdf::PdfStandard::A_2a,
71                PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b,
72                PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u,
73                PdfStandard::A_3a => typst_pdf::PdfStandard::A_3a,
74                PdfStandard::A_4 => typst_pdf::PdfStandard::A_4,
75                PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f,
76                PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e,
77                PdfStandard::Ua_1 => typst_pdf::PdfStandard::Ua_1,
78            })
79            .collect::<Vec<_>>(),
80    )
81    .map_err(|err| err.message().clone())
82    .context("prepare pdf standards")?;
83
84    let tagged = !no_pdf_tags && pages.is_none();
85    // todo: emit warning diag
86    if pages.is_some() && !no_pdf_tags {
87        log::warn!(
88            "the resulting PDF will be inaccessible because using --pages implies --no-pdf-tags"
89        );
90    }
91    if !tagged {
92        const ACCESSIBLE: &[(PdfStandard, &str)] = &[
93            (PdfStandard::A_1a, "PDF/A-1a"),
94            (PdfStandard::A_2a, "PDF/A-2a"),
95            (PdfStandard::A_3a, "PDF/A-3a"),
96            (PdfStandard::Ua_1, "PDF/UA-1"),
97        ];
98
99        for (standard, name) in ACCESSIBLE {
100            if pdf_standards.contains(standard) {
101                if no_pdf_tags {
102                    log::warn!("cannot disable PDF tags when exporting a {name} document");
103                } else {
104                    log::warn!(
105                        "cannot disable PDF tags when exporting a {name} document. Hint: using --pages implies --no-pdf-tags"
106                    );
107                }
108            }
109        }
110    }
111
112    Ok(PdfOptions {
113        page_ranges: pages.map(exported_page_ranges),
114        timestamp,
115        standards,
116        tagged,
117        ..Default::default()
118    })
119}
120
121fn explicit_timestamp(timestamp: i64) -> Result<Option<Timestamp>> {
122    let datetime =
123        chrono::DateTime::from_timestamp(timestamp, 0).context("timestamp is out of range")?;
124    Ok(convert_datetime(datetime).map(Timestamp::new_utc))
125}
126
127fn environment_timestamp(local_datetime: LocalDatetime) -> Option<Timestamp> {
128    let datetime = Datetime::Datetime(local_datetime.datetime);
129
130    match local_datetime.local_offset_minutes {
131        Some(offset) => Timestamp::new_local(datetime, offset),
132        None => Some(Timestamp::new_utc(datetime)),
133    }
134}
135
136fn convert_datetime<Tz: chrono::TimeZone>(date_time: chrono::DateTime<Tz>) -> Option<Datetime> {
137    Datetime::from_ymd_hms(
138        date_time.year(),
139        date_time.month().try_into().ok()?,
140        date_time.day().try_into().ok()?,
141        date_time.hour().try_into().ok()?,
142        date_time.minute().try_into().ok()?,
143        date_time.second().try_into().ok()?,
144    )
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn local_datetime(local_offset_minutes: Option<i32>) -> LocalDatetime {
152        LocalDatetime::from_ymd_hms(2024, 12, 17, 10, 11, 12, local_offset_minutes).unwrap()
153    }
154
155    fn export_with_timestamp(timestamp: Timestamp) -> Vec<u8> {
156        let document = TypstPagedDocument::new(Default::default(), Default::default());
157        typst_pdf::pdf(
158            &document,
159            &PdfOptions {
160                timestamp: Some(timestamp),
161                tagged: false,
162                ..Default::default()
163            },
164        )
165        .unwrap()
166    }
167
168    fn assert_pdf_contains(pdf: &[u8], expected: &str) {
169        assert!(
170            pdf.windows(expected.len())
171                .any(|window| window == expected.as_bytes()),
172            "PDF metadata should contain {expected}"
173        );
174    }
175
176    fn assert_pdf_dates(pdf: &[u8], pdf_date: &str, xmp_date: &str) {
177        for field in ["CreationDate", "ModDate"] {
178            assert_pdf_contains(pdf, &format!("/{field}({pdf_date})"));
179        }
180        for field in ["CreateDate", "ModifyDate"] {
181            assert_pdf_contains(pdf, &format!("<xmp:{field}>{xmp_date}</xmp:{field}>"));
182        }
183    }
184
185    #[test]
186    fn environment_timestamp_uses_local_timezone() {
187        let timestamp = environment_timestamp(local_datetime(Some(0))).unwrap();
188
189        assert!(
190            format!("{timestamp:?}").contains("timezone: Local"),
191            "default PDF timestamp should retain the local timezone: {timestamp:?}"
192        );
193    }
194
195    #[test]
196    fn explicit_pdf_timestamp_uses_utc() {
197        let timestamp = pdf_options(None, &[], false, Some(0))
198            .unwrap()
199            .timestamp
200            .unwrap();
201        let pdf = export_with_timestamp(timestamp);
202
203        assert_pdf_dates(&pdf, "D:19700101000000Z", "1970-01-01T00:00:00+00:00");
204    }
205
206    #[test]
207    fn explicit_timestamp_outside_typst_range_is_omitted() {
208        // Chrono accepts this instant, but Typst's datetime ends at year 9999.
209        let timestamp = explicit_timestamp(253_402_300_800).unwrap();
210
211        assert!(timestamp.is_none());
212    }
213
214    #[test]
215    fn invalid_explicit_timestamp_is_rejected() {
216        let error = explicit_timestamp(i64::MAX).unwrap_err();
217
218        assert_eq!(error.to_string(), "timestamp is out of range");
219    }
220
221    #[test]
222    fn capability_free_pdf_timestamp_uses_utc_fallback() {
223        let timestamp = environment_timestamp(local_datetime(None)).unwrap();
224        let pdf = export_with_timestamp(timestamp);
225
226        assert_pdf_dates(&pdf, "D:20241217101112Z", "2024-12-17T10:11:12+00:00");
227    }
228
229    #[test]
230    fn local_pdf_timestamp_preserves_wall_time_and_offset() {
231        for (offset_minutes, pdf_date, xmp_date) in [
232            (
233                5 * 60 + 30,
234                "D:20241217101112+05'30",
235                "2024-12-17T10:11:12+05:30",
236            ),
237            (
238                -(3 * 60 + 30),
239                "D:20241217101112-03'30",
240                "2024-12-17T10:11:12-03:30",
241            ),
242        ] {
243            let timestamp = environment_timestamp(local_datetime(Some(offset_minutes))).unwrap();
244            let pdf = export_with_timestamp(timestamp);
245
246            assert_pdf_dates(&pdf, pdf_date, xmp_date);
247        }
248    }
249}