1use 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
15pub 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 Ok(Bytes::new(typst_pdf::pdf(doc, &options)?))
39 }
40}
41
42pub 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 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 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 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}