Skip to main content

embedded_typst/
world.rs

1//! The [`World`] implementation.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, OnceLock, RwLock};
6
7use typst::diag::{FileError, FileResult};
8use typst::foundations::{Bytes, Datetime, Dict, Duration};
9use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
10use typst::text::{Font, FontBook};
11use typst::utils::LazyHash;
12use typst::{Library, LibraryExt, World};
13
14use crate::Fonts;
15
16/// The id of a file in the project (not in a package).
17pub fn project_file(path: VirtualPath) -> FileId {
18    RootedPath::new(VirtualRoot::Project, path).intern()
19}
20
21/// Lazy cache entry for a file in the virtual filesystem.
22struct FileSlot {
23    source: OnceLock<FileResult<Source>>,
24    bytes: OnceLock<FileResult<Bytes>>,
25}
26
27impl FileSlot {
28    fn new() -> Self {
29        Self {
30            source: OnceLock::new(),
31            bytes: OnceLock::new(),
32        }
33    }
34}
35
36/// A file Typst read during a compilation, see [`EmbeddedWorld::dependencies`].
37#[cfg(not(target_arch = "wasm32"))]
38pub struct Dependency {
39    /// The project-relative path, or `@namespace/name:version/path` for a
40    /// package file.
41    pub name: String,
42    /// The file on disk.
43    pub path: PathBuf,
44    /// The contents Typst saw.
45    pub data: Bytes,
46}
47
48/// A Typst world for one compilation: the main source, in-memory files and,
49/// on native targets, files looked up in a list of root directories and in
50/// Typst packages.
51///
52/// Files are cached for the lifetime of the world, so create a new world for
53/// every compilation and share the [`Fonts`] between them.
54pub struct EmbeddedWorld {
55    main: Source,
56    library: LazyHash<Library>,
57    fonts: Arc<Fonts>,
58    files: RwLock<HashMap<FileId, FileSlot>>,
59    roots: Vec<PathBuf>,
60    #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
61    packages: Option<crate::Packages>,
62    fixed_now: Option<chrono::DateTime<chrono::Utc>>,
63    #[cfg(not(target_arch = "wasm32"))]
64    now: OnceLock<chrono::DateTime<chrono::Utc>>,
65}
66
67impl EmbeddedWorld {
68    /// A world whose main file is `main` with the text `source`, without
69    /// roots, inputs or packages.
70    pub fn new(main: VirtualPath, source: impl Into<String>, fonts: Arc<Fonts>) -> Self {
71        Self {
72            main: Source::new(project_file(main), source.into()),
73            library: LazyHash::new(Library::default()),
74            fonts,
75            files: RwLock::new(HashMap::new()),
76            roots: Vec::new(),
77            #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
78            packages: None,
79            fixed_now: None,
80            #[cfg(not(target_arch = "wasm32"))]
81            now: OnceLock::new(),
82        }
83    }
84
85    /// Look up project files that are not in memory in these directories, in
86    /// order. The first root that contains the file wins.
87    pub fn with_roots(mut self, roots: Vec<PathBuf>) -> Self {
88        self.roots = roots;
89        self
90    }
91
92    /// Make `inputs` available as `sys.inputs`.
93    pub fn with_inputs(mut self, inputs: Dict) -> Self {
94        self.library = LazyHash::new(Library::builder().with_inputs(inputs).build());
95        self
96    }
97
98    /// Answer `datetime.today()` from this Unix timestamp (in UTC) instead of
99    /// the system clock, for reproducible output. Without it, Wasm has no
100    /// date at all.
101    pub fn with_creation_timestamp(mut self, seconds: i64) -> Self {
102        self.fixed_now = chrono::DateTime::from_timestamp(seconds, 0);
103        self
104    }
105
106    /// Resolve `@namespace/name:version` imports from `packages`.
107    #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
108    pub fn with_packages(mut self, packages: crate::Packages) -> Self {
109        self.packages = Some(packages);
110        self
111    }
112
113    /// Serve `contents` as the project file `path`, in front of the roots.
114    pub fn add_file(&self, path: VirtualPath, contents: impl AsRef<[u8]> + Send + Sync + 'static) {
115        let mut files = self
116            .files
117            .write()
118            .expect("virtual filesystem lock poisoned");
119        let slot = files
120            .entry(project_file(path))
121            .or_insert_with(FileSlot::new);
122        let _ = slot.bytes.set(Ok(Bytes::new(contents)));
123    }
124
125    /// The fonts of this world.
126    pub fn fonts(&self) -> &Fonts {
127        &self.fonts
128    }
129
130    /// The file on disk for `id`: a project file is looked up in every root,
131    /// a package file in its package directory.
132    pub fn path_for(&self, id: FileId) -> FileResult<PathBuf> {
133        let not_found = || FileError::NotFound(id.vpath().get_with_slash().into());
134        match id.root() {
135            VirtualRoot::Project => self
136                .roots
137                .iter()
138                .filter_map(|root| id.vpath().realize(root).ok())
139                .find(|path| path.is_file())
140                .ok_or_else(not_found),
141            #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
142            VirtualRoot::Package(spec) => {
143                let packages = self.packages.as_ref().ok_or_else(not_found)?;
144                let root = packages.root(spec)?;
145                id.vpath().realize(&root).map_err(|_| not_found())
146            }
147            #[cfg(not(all(feature = "packages", not(target_arch = "wasm32"))))]
148            VirtualRoot::Package(_) => Err(not_found()),
149        }
150    }
151
152    /// The path to show in diagnostics: the real file, relative to the
153    /// working directory where possible, otherwise the virtual path.
154    pub fn display_path(&self, id: FileId) -> String {
155        let Ok(path) = self.path_for(id) else {
156            return id.vpath().get_without_slash().to_owned();
157        };
158        std::env::current_dir()
159            .ok()
160            .and_then(|cwd| cwd.canonicalize().ok())
161            .and_then(|cwd| path.strip_prefix(cwd).ok().map(Path::to_path_buf))
162            .unwrap_or(path)
163            .display()
164            .to_string()
165    }
166
167    /// Every file Typst read from disk so far, except the main file and
168    /// in-memory files.
169    #[cfg(not(target_arch = "wasm32"))]
170    pub fn dependencies(&self) -> Vec<Dependency> {
171        let files = self.files.read().expect("virtual filesystem lock poisoned");
172        let mut dependencies = Vec::new();
173        for (id, slot) in files.iter() {
174            let data = match (slot.bytes.get(), slot.source.get()) {
175                (Some(Ok(bytes)), _) => bytes.clone(),
176                (_, Some(Ok(source))) => Bytes::from_string(source.text().to_owned()),
177                _ => continue,
178            };
179            let Ok(path) = self.path_for(*id) else {
180                continue;
181            };
182            let name = match id.root() {
183                VirtualRoot::Project => id.vpath().get_without_slash().to_owned(),
184                VirtualRoot::Package(spec) => format!("{spec}/{}", id.vpath().get_without_slash()),
185            };
186            dependencies.push(Dependency { name, path, data });
187        }
188        dependencies
189    }
190
191    fn cached_or_load<T: Clone>(
192        &self,
193        id: FileId,
194        cell: impl Fn(&FileSlot) -> &OnceLock<FileResult<T>>,
195        load: impl FnOnce(&Path) -> FileResult<T>,
196    ) -> FileResult<T> {
197        {
198            let files = self.files.read().expect("virtual filesystem lock poisoned");
199            if let Some(result) = files.get(&id).and_then(|slot| cell(slot).get()) {
200                return result.clone();
201            }
202        }
203
204        let result = self.path_for(id).and_then(|path| load(&path));
205
206        let mut files = self
207            .files
208            .write()
209            .expect("virtual filesystem lock poisoned");
210        let slot = files.entry(id).or_insert_with(FileSlot::new);
211        cell(slot).get_or_init(|| result.clone()).clone()
212    }
213}
214
215impl World for EmbeddedWorld {
216    fn library(&self) -> &LazyHash<Library> {
217        &self.library
218    }
219
220    fn book(&self) -> &LazyHash<FontBook> {
221        self.fonts.book()
222    }
223
224    fn main(&self) -> FileId {
225        self.main.id()
226    }
227
228    fn source(&self, id: FileId) -> FileResult<Source> {
229        if id == self.main.id() {
230            return Ok(self.main.clone());
231        }
232
233        {
234            let files = self.files.read().expect("virtual filesystem lock poisoned");
235            if let Some(Ok(bytes)) = files.get(&id).and_then(|slot| slot.bytes.get()) {
236                let text =
237                    std::str::from_utf8(bytes.as_slice()).map_err(|_| FileError::InvalidUtf8)?;
238                return Ok(Source::new(id, text.to_owned()));
239            }
240        }
241
242        self.cached_or_load(
243            id,
244            |slot| &slot.source,
245            |path| {
246                std::fs::read_to_string(path)
247                    .map_err(|error| FileError::from_io(error, path))
248                    .map(|text| Source::new(id, text))
249            },
250        )
251    }
252
253    fn file(&self, id: FileId) -> FileResult<Bytes> {
254        self.cached_or_load(
255            id,
256            |slot| &slot.bytes,
257            |path| {
258                std::fs::read(path)
259                    .map_err(|error| FileError::from_io(error, path))
260                    .map(Bytes::new)
261            },
262        )
263    }
264
265    fn font(&self, index: usize) -> Option<Font> {
266        self.fonts.font(index)
267    }
268
269    fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
270        use chrono::Datelike;
271
272        // A fixed creation timestamp is interpreted as UTC so the result does
273        // not depend on the machine's time zone. The system clock is read
274        // once per world, so every call agrees.
275        let (now, local) = match self.fixed_now {
276            Some(now) => (now, false),
277            #[cfg(not(target_arch = "wasm32"))]
278            None => (*self.now.get_or_init(chrono::Utc::now), true),
279            #[cfg(target_arch = "wasm32")]
280            None => return None,
281        };
282        let date = match offset {
283            #[cfg(not(target_arch = "wasm32"))]
284            None if local => now.with_timezone(&chrono::Local).date_naive(),
285            None => now.date_naive(),
286            Some(offset) => {
287                (now + chrono::Duration::try_seconds(offset.seconds() as i64)?).date_naive()
288            }
289        };
290        #[cfg(target_arch = "wasm32")]
291        let _ = local;
292        Datetime::from_ymd(
293            date.year(),
294            date.month().try_into().ok()?,
295            date.day().try_into().ok()?,
296        )
297    }
298}