typst_batch/world.rs
1//! `SystemWorld` implementation - the core World trait.
2//!
3//! This module implements Typst's `World` trait, which provides the compilation
4//! environment for Typst documents. The `SystemWorld` is a lightweight per-compilation
5//! state that references globally shared resources (fonts, packages, library).
6//!
7//! # Architecture
8//!
9//! ```text
10//! SystemWorld (per-compilation, ~lightweight)
11//! ├── root: PathBuf // Project root for path resolution
12//! ├── main: FileId // Entry point file ID
13//! ├── fonts: &'static Fonts // → Global shared fonts
14//! ├── library: LibraryRef // → Global or custom library
15//! └── now: Now // Lazy datetime
16//!
17//! World trait methods:
18//! ├── library() → &GLOBAL_LIBRARY or custom
19//! ├── book() → &fonts.book
20//! ├── main() → main FileId
21//! ├── source() → FileSlot cache
22//! ├── file() → FileSlot cache
23//! ├── font() → fonts.fonts[index]
24//! └── today() → Now (lazy UTC)
25//! ```
26//!
27//! # Performance
28//!
29//! Creating a `SystemWorld` is cheap because:
30//! - Fonts, packages, and library are globally shared (static references)
31//! - File cache is global (not per-instance)
32//! - Datetime is lazily computed on first access
33//!
34//! # Custom Inputs (sys.inputs)
35//!
36//! For documents that need `sys.inputs`, use [`SystemWorld::with_inputs`]
37//! or create a custom library with [`crate::library::create_library_with_inputs`].
38
39use std::path::{Path, PathBuf};
40use std::sync::OnceLock;
41
42use chrono::{DateTime, Datelike, FixedOffset, Local, Utc};
43use typst::diag::FileResult;
44use typst::foundations::{Bytes, Datetime, Dict};
45use typst::syntax::{FileId, Source, VirtualPath};
46use typst::text::{Font, FontBook};
47use typst::utils::LazyHash;
48use typst::{Library, World};
49use typst_kit::fonts::Fonts;
50
51use crate::file::{FileSlot, VirtualFileSystem, GLOBAL_FILE_CACHE};
52use crate::font::get_fonts;
53use crate::library::{create_library_with_inputs, GLOBAL_LIBRARY};
54
55// =============================================================================
56// Path Utilities
57// =============================================================================
58
59/// Normalize a file system path to absolute form.
60///
61/// Tries `canonicalize()` first (resolves symlinks, `.`, `..`).
62/// Falls back to:
63/// - Return as-is if already absolute
64/// - Join with current directory if relative
65#[inline]
66fn normalize_path(path: &Path) -> PathBuf {
67 path.canonicalize().unwrap_or_else(|_| {
68 if path.is_absolute() {
69 path.to_path_buf()
70 } else {
71 std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
72 }
73 })
74}
75
76// =============================================================================
77// DateTime handling
78// =============================================================================
79
80/// Lazy-captured datetime for consistent `World::today()` within a compilation.
81///
82/// The current time is captured on first access and reused for consistency.
83struct LazyNow(OnceLock<DateTime<Utc>>);
84
85// =============================================================================
86// Library Reference
87// =============================================================================
88
89/// Reference to either global shared library or a custom library instance.
90///
91/// This allows `SystemWorld` to efficiently use the global library for batch
92/// compilation, while supporting custom libraries with `sys.inputs` for
93/// single document compilation.
94enum LibraryRef {
95 /// Reference to the global shared library (no inputs).
96 Global,
97 /// Custom library instance with specific inputs.
98 Custom(LazyHash<Library>),
99}
100
101impl LibraryRef {
102 /// Get a reference to the underlying library.
103 fn get(&self) -> &LazyHash<Library> {
104 match self {
105 Self::Global => &GLOBAL_LIBRARY,
106 Self::Custom(lib) => lib,
107 }
108 }
109}
110
111// =============================================================================
112// SystemWorld
113// =============================================================================
114
115/// A world that provides access to the operating system.
116///
117/// This struct is cheap to create because all expensive resources (fonts,
118/// packages, library, file cache) are globally shared.
119///
120/// # Thread Safety
121///
122/// `SystemWorld` is `Send + Sync` because all mutable state is behind
123/// thread-safe locks in global statics.
124///
125/// # Custom Inputs
126///
127/// By default, `SystemWorld` uses the global shared library (no `sys.inputs`).
128/// To provide custom inputs, use [`SystemWorld::with_inputs`]:
129///
130/// ```ignore
131/// let world = SystemWorld::new(path, root)
132/// .with_inputs([("title", "Hello"), ("author", "Alice")]);
133/// ```
134pub struct SystemWorld {
135 /// The root relative to which absolute paths are resolved.
136 /// This is typically the project directory containing `tola.toml`.
137 root: PathBuf,
138
139 /// The input path (main entry point).
140 /// This is the `FileId` of the file being compiled.
141 main: FileId,
142
143 /// Reference to global fonts (initialized on first use).
144 /// This is a static reference to avoid allocation per compilation.
145 fonts: &'static (Fonts, LazyHash<FontBook>),
146
147 /// Library reference - either global or custom with inputs.
148 library: LibraryRef,
149
150 /// The current datetime if requested.
151 /// Lazily initialized to ensure consistent time throughout compilation.
152 now: LazyNow,
153}
154
155impl SystemWorld {
156 /// Create a new world for compiling a specific file.
157 ///
158 /// This is cheap because fonts/packages/library/file-cache are globally shared.
159 /// No per-instance allocation is needed.
160 ///
161 /// # Arguments
162 ///
163 /// * `entry_file` - Path to the `.typ` file to compile
164 /// * `root_dir` - Project root directory for resolving imports
165 ///
166 /// # Returns
167 ///
168 /// A new `SystemWorld` ready for compilation.
169 pub fn new(entry_file: &Path, root_dir: &Path) -> Self {
170 // Canonicalize root path for consistent path resolution
171 let root = normalize_path(root_dir);
172
173 // Resolve the virtual path of the main file within the project root.
174 // Virtual paths are root-relative and use forward slashes.
175 let entry_abs = normalize_path(entry_file);
176 let virtual_path = VirtualPath::within_root(&entry_abs, &root)
177 .unwrap_or_else(|| VirtualPath::new(entry_file.file_name().unwrap()));
178 let main = FileId::new(None, virtual_path);
179
180 // Get global fonts. Fonts are already initialized via warmup_with_font_dirs().
181 // If not yet initialized, this returns an empty font set.
182 let fonts = get_fonts(&[]);
183
184 Self {
185 root,
186 main,
187 fonts,
188 library: LibraryRef::Global,
189 now: LazyNow(OnceLock::new()),
190 }
191 }
192
193 /// Configure `sys.inputs` for the compilation.
194 ///
195 /// This creates a custom library with the specified inputs accessible
196 /// via `sys.inputs` in Typst documents.
197 ///
198 /// # Arguments
199 ///
200 /// * `inputs` - Key-value pairs to make available as `sys.inputs`
201 ///
202 /// # Example
203 ///
204 /// ```ignore
205 /// use typst_batch::world::SystemWorld;
206 /// use std::path::Path;
207 ///
208 /// let world = SystemWorld::new(Path::new("doc.typ"), Path::new("."))
209 /// .with_inputs([("title", "Hello"), ("author", "Alice")]);
210 /// ```
211 ///
212 /// In your Typst document:
213 /// ```typst
214 /// #let title = sys.inputs.at("title", default: "Untitled")
215 /// = #title
216 /// ```
217 ///
218 /// # Performance Note
219 ///
220 /// Using this method creates a new library instance, bypassing the global
221 /// shared library. For batch compilation without inputs, use [`SystemWorld::new`].
222 pub fn with_inputs<I, K, V>(mut self, inputs: I) -> Self
223 where
224 I: IntoIterator<Item = (K, V)>,
225 K: Into<typst::foundations::Str>,
226 V: typst::foundations::IntoValue,
227 {
228
229 let dict: Dict = inputs
230 .into_iter()
231 .map(|(k, v)| (k.into(), v.into_value()))
232 .collect();
233
234 self.library = LibraryRef::Custom(create_library_with_inputs(dict));
235 self
236 }
237
238 /// Configure `sys.inputs` from a pre-built `Dict`.
239 ///
240 /// This is useful when you already have a `Dict` of inputs.
241 ///
242 /// # Example
243 ///
244 /// ```ignore
245 /// use typst::foundations::{Dict, IntoValue};
246 ///
247 /// let mut inputs = Dict::new();
248 /// inputs.insert("version".into(), "1.0".into_value());
249 ///
250 /// let world = SystemWorld::new(path, root).with_inputs_dict(inputs);
251 /// ```
252 pub fn with_inputs_dict(mut self, inputs: Dict) -> Self {
253 self.library = LibraryRef::Custom(create_library_with_inputs(inputs));
254 self
255 }
256
257 /// Configure a custom library for the compilation.
258 ///
259 /// Use this for full control over the library configuration.
260 /// Create a custom library with [`crate::library::create_library_with_inputs`].
261 pub fn with_library(mut self, library: LazyHash<Library>) -> Self {
262 self.library = LibraryRef::Custom(library);
263 self
264 }
265
266 /// Get the project root directory.
267 pub fn root(&self) -> &Path {
268 &self.root
269 }
270
271 /// Access the canonical slot for the given file id from global cache.
272 ///
273 /// Creates a new slot if one doesn't exist. The callback receives
274 /// mutable access to the slot for reading source/file data.
275 #[allow(clippy::unused_self)]
276 fn slot<F, T>(&self, id: FileId, f: F) -> T
277 where
278 F: FnOnce(&mut FileSlot) -> T,
279 {
280 let mut cache = GLOBAL_FILE_CACHE.write();
281 f(cache.entry(id).or_insert_with(|| FileSlot::new(id)))
282 }
283
284 /// Access a file slot with virtual file system support.
285 #[allow(clippy::unused_self)]
286 fn slot_virtual<V, F, T>(&self, id: FileId, virtual_fs: &V, f: F) -> T
287 where
288 V: VirtualFileSystem,
289 F: FnOnce(&mut FileSlot, &V) -> T,
290 {
291 let mut cache = GLOBAL_FILE_CACHE.write();
292 let slot = cache.entry(id).or_insert_with(|| FileSlot::new(id));
293 f(slot, virtual_fs)
294 }
295
296 /// Load source with virtual file system support.
297 pub fn source_with_virtual<V: VirtualFileSystem>(
298 &self,
299 id: FileId,
300 virtual_fs: &V,
301 ) -> FileResult<Source> {
302 self.slot_virtual(id, virtual_fs, |slot, vfs| {
303 slot.source_with_virtual(&self.root, vfs)
304 })
305 }
306
307 /// Load file with virtual file system support.
308 pub fn file_with_virtual<V: VirtualFileSystem>(
309 &self,
310 id: FileId,
311 virtual_fs: &V,
312 ) -> FileResult<Bytes> {
313 self.slot_virtual(id, virtual_fs, |slot, vfs| {
314 slot.file_with_virtual(&self.root, vfs)
315 })
316 }
317}
318
319/// Implementation of Typst's `World` trait.
320///
321/// This trait provides the compilation environment:
322/// - Standard library access
323/// - Font discovery
324/// - File system access
325/// - Package management (via file resolution)
326/// - Current date/time
327impl World for SystemWorld {
328 /// Returns the standard library.
329 ///
330 /// Returns either the global shared library or a custom library
331 /// with `sys.inputs` if configured via [`SystemWorld::with_inputs`].
332 fn library(&self) -> &LazyHash<Library> {
333 self.library.get()
334 }
335
336 /// Returns the font book for font lookup.
337 ///
338 /// The font book indexes all available fonts for name-based lookup.
339 fn book(&self) -> &LazyHash<FontBook> {
340 &self.fonts.1
341 }
342
343 /// Returns the main source file ID.
344 ///
345 /// This is the entry point for compilation.
346 fn main(&self) -> FileId {
347 self.main
348 }
349
350 /// Load a source file by ID.
351 ///
352 /// Returns the parsed source code, using the file slot cache
353 /// for incremental compilation.
354 ///
355 /// Uses the global virtual data provider registered via
356 /// [`crate::file::set_virtual_provider`].
357 fn source(&self, id: FileId) -> FileResult<Source> {
358 self.slot(id, |slot| slot.source_with_global_virtual(&self.root))
359 }
360
361 /// Load a file's raw bytes by ID.
362 ///
363 /// Used for binary files (images, etc.) that don't need parsing.
364 ///
365 /// Uses the global virtual data provider registered via
366 /// [`crate::file::set_virtual_provider`].
367 fn file(&self, id: FileId) -> FileResult<Bytes> {
368 self.slot(id, |slot| slot.file_with_global_virtual(&self.root))
369 }
370
371 /// Load a font by index.
372 ///
373 /// Fonts are indexed in the order they were discovered during
374 /// font search. The index comes from font book lookups.
375 fn font(&self, index: usize) -> Option<Font> {
376 self.fonts.0.fonts.get(index)?.get()
377 }
378
379 /// Get the current date.
380 ///
381 /// Returns the date at the time of first access within this compilation.
382 /// The time is captured once and reused for consistency.
383 ///
384 /// # Arguments
385 ///
386 /// * `offset` - Optional UTC offset in hours. If `None`, uses local timezone.
387 fn today(&self, offset: Option<i64>) -> Option<Datetime> {
388 let now = self.now.0.get_or_init(Utc::now);
389
390 // Apply timezone offset
391 let with_offset = match offset {
392 None => now.with_timezone(&Local).fixed_offset(),
393 Some(hours) => {
394 let seconds = i32::try_from(hours).ok()?.checked_mul(3600)?;
395 now.with_timezone(&FixedOffset::east_opt(seconds)?)
396 }
397 };
398
399 // Convert to Typst's Datetime type
400 Datetime::from_ymd(
401 with_offset.year(),
402 with_offset.month().try_into().ok()?,
403 with_offset.day().try_into().ok()?,
404 )
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use std::fs;
412 use tempfile::TempDir;
413
414 #[test]
415 fn test_create_system_world() {
416 let dir = TempDir::new().unwrap();
417 let file_path = dir.path().join("test.typ");
418 fs::write(&file_path, "= Hello").unwrap();
419
420 let _world = SystemWorld::new(&file_path, dir.path());
421 // If we get here without panicking, the world was created successfully
422 }
423
424 #[test]
425 fn test_world_library_access() {
426 let dir = TempDir::new().unwrap();
427 let file_path = dir.path().join("test.typ");
428 fs::write(&file_path, "= Hello").unwrap();
429
430 let world = SystemWorld::new(&file_path, dir.path());
431 let _lib = world.library();
432 // Should not panic
433 }
434
435 #[test]
436 fn test_world_book_access() {
437 let dir = TempDir::new().unwrap();
438 let file_path = dir.path().join("test.typ");
439 fs::write(&file_path, "= Hello").unwrap();
440
441 let world = SystemWorld::new(&file_path, dir.path());
442 let book = world.book();
443 // Should have some fonts
444 assert!(book.families().count() > 0);
445 }
446
447 #[test]
448 fn test_world_main_file() {
449 let dir = TempDir::new().unwrap();
450 let file_path = dir.path().join("test.typ");
451 fs::write(&file_path, "= Hello").unwrap();
452
453 let world = SystemWorld::new(&file_path, dir.path());
454 let main = world.main();
455 // Main file should have the correct virtual path
456 assert!(main.vpath().as_rootless_path().ends_with("test.typ"));
457 }
458
459 #[test]
460 fn test_world_source_loading() {
461 let dir = TempDir::new().unwrap();
462 let file_path = dir.path().join("test.typ");
463 fs::write(&file_path, "= Hello World").unwrap();
464
465 let world = SystemWorld::new(&file_path, dir.path());
466 let source = world.source(world.main());
467 assert!(source.is_ok());
468 assert!(source.unwrap().text().contains("Hello World"));
469 }
470
471 #[test]
472 fn test_world_today() {
473 let dir = TempDir::new().unwrap();
474 let file_path = dir.path().join("test.typ");
475 fs::write(&file_path, "= Hello").unwrap();
476
477 let world = SystemWorld::new(&file_path, dir.path());
478
479 // Test with local timezone
480 let today = world.today(None);
481 assert!(today.is_some());
482
483 // Test with UTC offset
484 let today_utc = world.today(Some(0));
485 assert!(today_utc.is_some());
486 }
487
488 #[test]
489 fn test_world_font_access() {
490 let dir = TempDir::new().unwrap();
491 let file_path = dir.path().join("test.typ");
492 fs::write(&file_path, "= Hello").unwrap();
493
494 let world = SystemWorld::new(&file_path, dir.path());
495
496 // Should be able to access at least one font
497 let font = world.font(0);
498 // May be None in minimal environments, but shouldn't panic
499 let _ = font;
500 }
501}