1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use chrono::Datelike;
use typst::diag::{FileError, FileResult, PackageError};
use typst::foundations::{Bytes, Datetime, Duration};
use typst::syntax::{FileId, Source, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Features, Library, LibraryExt};
use crate::clock::Clock;
use crate::data::DataSet;
use crate::error::{PublishError, Result};
use crate::fonts::SharedFonts;
use crate::template::project_file_id;
pub(crate) struct PublishWorld {
root: Option<PathBuf>,
main_source: Source,
library: LazyHash<Library>,
fonts: Arc<typst_kit::fonts::FontStore>,
virtual_files: HashMap<FileId, Bytes>,
clock: Clock,
// Memoized wall-clock instant for `today()`. Captured on the first call so
// repeated `datetime.today()` invocations within a single render agree even
// across a second/day boundary (mirrors typst-kit's `datetime::Time`).
// Unused for `Clock::Fixed`.
now: OnceLock<chrono::DateTime<chrono::Utc>>,
}
impl PublishWorld {
pub fn new(
root: Option<PathBuf>,
main_source: Source,
shared_fonts: &SharedFonts,
data: DataSet,
clock: Clock,
features: Features,
) -> Result<Self> {
// `features` selects optional Typst capabilities. The PDF path passes
// `Features::default()`. The HTML path enables `Feature::Html` so the
// `html` module (`html.elem`, ...) is defined for templates.
let mut builder = Library::builder().with_features(features);
// `sys.inputs` (independent of any virtual files).
if let Some(dict) = data.inputs {
builder = builder.with_inputs(dict);
}
let library = builder.build();
// Virtual files, each mapped through `project_file_id` (the only path to
// a `FileId`, which keeps names confined to the project root). Reject a
// name that collides with the template's own file, which would otherwise
// silently shadow it. Duplicate names among the files were already
// rejected when the `DataSet` was built.
let main_id = main_source.id();
let mut virtual_files = HashMap::with_capacity(data.files.len());
for (name, bytes) in data.files {
let file_id = project_file_id(name.as_str());
if file_id == main_id {
return Err(PublishError::InvalidTemplate(format!(
"virtual file '{name}' collides with the template's file name"
)));
}
virtual_files.insert(file_id, Bytes::new(bytes));
}
Ok(Self {
root,
main_source,
library: LazyHash::new(library),
fonts: Arc::clone(&shared_fonts.store),
virtual_files,
clock,
now: OnceLock::new(),
})
}
fn resolve_path(&self, id: FileId) -> FileResult<PathBuf> {
// Package imports (`@preview/...`) are not supported, we never fetch
// or resolve them. Deny explicitly rather than silently mis-resolving
// the in-package path against the local root.
if matches!(id.root(), VirtualRoot::Package(_)) {
return Err(FileError::Package(PackageError::Other(Some(
"package imports are not supported".into(),
))));
}
let root = self.root.as_ref().ok_or(FileError::AccessDenied)?;
let vpath = id.vpath();
// `realize` confines the virtual path to `root` and rejects traversal out of it.
// Map any failure to `AccessDenied` to preserve the prior contract.
vpath.realize(root).map_err(|_| FileError::AccessDenied)
}
}
impl typst::World for PublishWorld {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.fonts.book()
}
fn main(&self) -> FileId {
self.main_source.id()
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.main_source.id() {
return Ok(self.main_source.clone());
}
let path = self.resolve_path(id)?;
let text = std::fs::read_to_string(&path).map_err(|e| FileError::from_io(e, &path))?;
Ok(Source::new(id, text))
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if let Some(bytes) = self.virtual_files.get(&id) {
return Ok(bytes.clone());
}
let path = self.resolve_path(id)?;
let raw = std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))?;
Ok(Bytes::new(raw))
}
fn font(&self, index: usize) -> Option<Font> {
self.fonts.font(index)
}
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
// `offset` is passed by the template via `datetime.today(offset: d)`. As
// of typst 0.15 it is a `Duration` (allowing sub-hour precision) rather
// than a whole-hour integer. `None` means the template asked for `auto`.
//
// Key point: an explicit offset is *absolute* (UTC + d), not "add d to the
// local time". So once a real offset is given, it no longer matters whether
// the clock is Utc or SystemLocal, the answer is UTC+d either way, which is
// why those two arms for `now` below have the same body. The clock only changes
// what `None` means, which is Utc -> UTC+0 or SystemLocal -> the host's local time.
// Fixed always returns the same date, so it deliberately ignores `offset`,
// which is why it is handled here first up front.
if let Clock::Fixed { year, month, day } = self.clock {
return Datetime::from_ymd(year, month, day);
}
// A Typst `Duration` reports its *total* length in seconds, which is
// exactly the eastward UTC offset we want to hand chrono.
let to_fixed = |d: Duration| -> Option<chrono::FixedOffset> {
let secs = i32::try_from(d.seconds() as i64).ok()?;
chrono::FixedOffset::east_opt(secs)
};
// Capture "now" once per render and reuse it for every `today()` call.
let now_utc = *self.now.get_or_init(chrono::Utc::now);
// Handle clocks based on the current time.
let now = match (self.clock, offset) {
// Utc: shift "now" by the requested offset, or UTC+0 if none was given.
(Clock::Utc, off) => {
let fixed = match off {
Some(d) => to_fixed(d)?,
None => chrono::FixedOffset::east_opt(0)?,
};
now_utc.with_timezone(&fixed).naive_local()
}
// SystemLocal but with an explicit offset. It's absolute, so we shift
// UTC by it and ignore the host's local zone (same result as Utc above).
(Clock::SystemLocal, Some(d)) => {
now_utc.with_timezone(&to_fixed(d)?).naive_local()
}
// SystemLocal with `auto`. Just use the host's local wall clock.
(Clock::SystemLocal, None) => now_utc.with_timezone(&chrono::Local).naive_local(),
(Clock::Fixed { .. }, _) => unreachable!("handled above"),
};
Datetime::from_ymd(
now.year(),
now.month().try_into().ok()?,
now.day().try_into().ok()?,
)
}
}
// Compile-time assertion that PublishWorld is Send + Sync (required by World trait).
const _: () = {
fn _assert<T: Send + Sync>() {}
#[allow(unused)]
fn _check() {
_assert::<PublishWorld>();
}
};