Skip to main content

typst_pack/
world.rs

1//! A complete Typst [`World`] backed by a [`Pack`].
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use typst::diag::{FileError, FileResult};
7use typst::foundations::{Bytes, Datetime, Dict, Duration};
8use typst::syntax::{FileId, RootedPath, Source, VirtualRoot};
9use typst::text::FontInfo;
10use typst::text::{Font, FontBook};
11use typst::utils::LazyHash;
12use typst::{Feature, Library, LibraryExt, World};
13use typst_kit::files::{FileLoader, FileStore};
14use typst_kit::fonts::{FontSource, FontStore};
15
16use crate::pack::Pack;
17
18/// A complete Typst [`World`] backed by a [`Pack`].
19///
20/// Project files and vendored package files come from the pack. Fonts come
21/// from the pack, plus any fonts configured on the builder. Files of packages
22/// that are not vendored are only available if a package loader is configured.
23/// Declared External Project Resources may come from external resource loaders;
24/// these loaders cannot replace packed files or supply Typst source or package
25/// files.
26pub struct PackWorld {
27    library: LazyHash<Library>,
28    main: FileId,
29    store: FileStore<PackLoader>,
30    fonts: FontStore,
31    clock: Clock,
32}
33
34impl PackWorld {
35    /// Starts configuring a world for the given pack.
36    pub fn builder(pack: Pack) -> PackWorldBuilder {
37        PackWorldBuilder::new(pack)
38    }
39
40    /// Creates a world with default configuration.
41    pub fn new(pack: Pack) -> Result<Self, PackWorldError> {
42        Self::builder(pack).build()
43    }
44
45    /// The pack this world serves resources from.
46    pub fn pack(&self) -> &Pack {
47        self.store.loader().pack.as_ref()
48    }
49}
50
51impl World for PackWorld {
52    fn library(&self) -> &LazyHash<Library> {
53        &self.library
54    }
55
56    fn book(&self) -> &LazyHash<FontBook> {
57        self.fonts.book()
58    }
59
60    fn main(&self) -> FileId {
61        self.main
62    }
63
64    fn source(&self, id: FileId) -> FileResult<Source> {
65        if matches!(id.root(), VirtualRoot::Project)
66            && self
67                .store
68                .loader()
69                .pack
70                .file(id.vpath().get_without_slash())
71                .is_none()
72        {
73            return Err(FileError::NotFound(PathBuf::from(
74                id.vpath().get_without_slash(),
75            )));
76        }
77        self.store.source(id)
78    }
79
80    fn file(&self, id: FileId) -> FileResult<Bytes> {
81        self.store.file(id)
82    }
83
84    fn font(&self, index: usize) -> Option<Font> {
85        self.fonts.font(index)
86    }
87
88    fn today(&self, #[allow(unused_variables)] offset: Option<Duration>) -> Option<Datetime> {
89        match &self.clock {
90            Clock::None => None,
91            // A fixed date is used as-is; the offset only matters relative to
92            // an instant, which a plain date does not carry.
93            Clock::Fixed(datetime) => Some(*datetime),
94            #[cfg(feature = "fs")]
95            Clock::System(time) => time.today(offset),
96        }
97    }
98}
99
100/// Where the world takes the current date from.
101enum Clock {
102    /// `datetime.today()` errors in document code.
103    None,
104    /// A fixed date, for reproducible output.
105    Fixed(Datetime),
106    /// The system clock.
107    #[cfg(feature = "fs")]
108    System(typst_kit::datetime::Time),
109}
110
111/// Serves file requests from a pack, with an optional fallback for packages
112/// that are not vendored.
113struct PackLoader {
114    pack: Arc<Pack>,
115    external_resource_loaders: Vec<Box<dyn FileLoader + Send + Sync>>,
116    package_loader: Option<Box<dyn FileLoader + Send + Sync>>,
117}
118
119pub(crate) fn load_external_resource(
120    loaders: &[Box<dyn FileLoader + Send + Sync>],
121    id: FileId,
122) -> FileResult<Bytes> {
123    for loader in loaders {
124        match loader.load(id) {
125            Err(FileError::NotFound(_)) => {}
126            result => return result,
127        }
128    }
129    Err(FileError::NotFound(PathBuf::from(
130        id.vpath().get_without_slash(),
131    )))
132}
133
134impl FileLoader for PackLoader {
135    fn load(&self, id: FileId) -> FileResult<Bytes> {
136        let path = id.vpath().get_without_slash();
137        match id.root() {
138            VirtualRoot::Project => {
139                if let Some(data) = self.pack.file(path) {
140                    return Ok(data.clone());
141                }
142                if !self.pack.is_external_resource(path) {
143                    return Err(FileError::NotFound(PathBuf::from(path)));
144                }
145                load_external_resource(&self.external_resource_loaders, id)
146            }
147            VirtualRoot::Package(spec) => {
148                if self.pack.has_package(spec) {
149                    self.pack
150                        .package_file(spec, path)
151                        .cloned()
152                        .ok_or_else(|| FileError::NotFound(PathBuf::from(path)))
153                } else if let Some(loader) = &self.package_loader {
154                    loader.load(id)
155                } else {
156                    Err(FileError::Other(Some(
157                        format!(
158                            "package {spec} is not vendored in the pack \
159                             and no package loader is configured"
160                        )
161                        .into(),
162                    )))
163                }
164            }
165        }
166    }
167}
168
169/// Configures a [`PackWorld`].
170pub struct PackWorldBuilder {
171    pack: Pack,
172    inputs: Dict,
173    features: Vec<Feature>,
174    clock: Clock,
175    #[cfg_attr(not(feature = "embedded-fonts"), allow(dead_code))]
176    embedded_fonts: bool,
177    extra_fonts: Vec<(BoxedFontSource, FontInfo)>,
178    external_resource_loaders: Vec<Box<dyn FileLoader + Send + Sync>>,
179    package_loader: Option<Box<dyn FileLoader + Send + Sync>>,
180}
181
182/// Adapter that lets heterogeneous font sources live in one list.
183struct BoxedFontSource(Box<dyn FontSource>);
184
185impl FontSource for BoxedFontSource {
186    fn load(&self) -> Option<Font> {
187        self.0.load()
188    }
189}
190
191impl PackWorldBuilder {
192    fn new(pack: Pack) -> Self {
193        Self {
194            pack,
195            inputs: Dict::new(),
196            features: Vec::new(),
197            clock: Clock::None,
198            embedded_fonts: cfg!(feature = "embedded-fonts"),
199            extra_fonts: Vec::new(),
200            external_resource_loaders: Vec::new(),
201            package_loader: None,
202        }
203    }
204
205    /// Values made available to document code as `sys.inputs`.
206    pub fn inputs(mut self, inputs: Dict) -> Self {
207        self.inputs = inputs;
208        self
209    }
210
211    /// Enables an experimental Typst language feature.
212    ///
213    /// [`Feature::Html`](typst::Feature::Html) is required for compiling to
214    /// [`OutputFormat::Html`](crate::OutputFormat::Html).
215    pub fn feature(mut self, feature: Feature) -> Self {
216        self.features.push(feature);
217        self
218    }
219
220    /// Uses a fixed date for `datetime.today()`, for reproducible output.
221    pub fn fixed_date(mut self, datetime: Datetime) -> Self {
222        self.clock = Clock::Fixed(datetime);
223        self
224    }
225
226    /// Uses the system clock for `datetime.today()`.
227    #[cfg(feature = "fs")]
228    pub fn system_date(mut self) -> Self {
229        self.clock = Clock::System(typst_kit::datetime::Time::system());
230        self
231    }
232
233    /// Whether to include Typst's default embedded fonts. Defaults to `true`
234    /// when the `embedded-fonts` feature is enabled.
235    #[cfg(feature = "embedded-fonts")]
236    pub fn embedded_fonts(mut self, include: bool) -> Self {
237        self.embedded_fonts = include;
238        self
239    }
240
241    /// Adds fonts on top of the ones embedded in the pack.
242    ///
243    /// These rank behind pack fonts but before embedded default fonts, so use
244    /// this for system fonts or other host-provided fonts. Accepts the same
245    /// `(source, info)` entries yielded by the `typst_kit::fonts` providers,
246    /// so fonts are only loaded into memory when actually used.
247    pub fn extra_fonts<T: FontSource>(
248        mut self,
249        fonts: impl IntoIterator<Item = (T, FontInfo)>,
250    ) -> Self {
251        self.extra_fonts.extend(
252            fonts
253                .into_iter()
254                .map(|(source, info)| (BoxedFontSource(Box::new(source)), info)),
255        );
256        self
257    }
258
259    /// Serves files of packages that are not vendored in the pack, e.g. from
260    /// a package cache or the network.
261    pub fn package_loader(mut self, loader: impl FileLoader + Send + Sync + 'static) -> Self {
262        self.package_loader = Some(Box::new(loader));
263        self
264    }
265
266    /// Adds a loader for declared External Project Resources.
267    ///
268    /// Loaders are tried in registration order after packed project files.
269    pub fn external_resource_loader(
270        mut self,
271        loader: impl FileLoader + Send + Sync + 'static,
272    ) -> Self {
273        self.external_resource_loaders.push(Box::new(loader));
274        self
275    }
276
277    /// Builds the world.
278    pub fn build(self) -> Result<PackWorld, PackWorldError> {
279        let entrypoint = self
280            .pack
281            .manifest()
282            .entrypoint()
283            .map_err(|err| PackWorldError::InvalidPack(err.to_string()))?;
284        let main = RootedPath::new(VirtualRoot::Project, entrypoint).intern();
285
286        let mut fonts = FontStore::new();
287        for pack_font in self.pack.fonts() {
288            let font = Font::new(pack_font.data.clone(), pack_font.entry.index)
289                .ok_or_else(|| PackWorldError::InvalidFont(pack_font.entry.path.clone()))?;
290            let info = font.info().clone();
291            fonts.push((font, info));
292        }
293        fonts.extend(self.extra_fonts);
294        #[cfg(feature = "embedded-fonts")]
295        if self.embedded_fonts {
296            fonts.extend(typst_kit::fonts::embedded());
297        }
298
299        let library = Library::builder()
300            .with_inputs(self.inputs)
301            .with_features(self.features.into_iter().collect())
302            .build();
303
304        Ok(PackWorld {
305            library: LazyHash::new(library),
306            main,
307            store: FileStore::new(PackLoader {
308                pack: Arc::new(self.pack),
309                external_resource_loaders: self.external_resource_loaders,
310                package_loader: self.package_loader,
311            }),
312            fonts,
313            clock: self.clock,
314        })
315    }
316}
317
318/// A [`FileLoader`] that resolves package files from standard system
319/// locations (and Typst Universe), for compiling packs whose dependencies are
320/// not vendored. Project file requests always fail: those must come from the
321/// pack.
322#[cfg(feature = "fs")]
323pub struct SystemPackageLoader(pub typst_kit::packages::SystemPackages);
324
325#[cfg(feature = "fs")]
326impl SystemPackageLoader {
327    /// Creates a loader using the standard package directories and the
328    /// official Typst Universe registry.
329    pub fn system() -> Self {
330        Self(typst_kit::packages::SystemPackages::new(
331            typst_kit::downloader::SystemDownloader::new(concat!(
332                "typst-pack/",
333                env!("CARGO_PKG_VERSION")
334            )),
335        ))
336    }
337
338    /// Creates a loader that only uses the standard local package
339    /// directories and never accesses the network.
340    pub fn offline() -> Self {
341        Self(typst_kit::packages::SystemPackages::new(OfflineDownloader))
342    }
343}
344
345/// A package downloader that refuses to download.
346///
347/// Plug this into [`typst_kit::packages::UniversePackages`] to guarantee
348/// that package resolution never accesses the network: every download
349/// attempt fails as not found, so only local directories (or the pack
350/// itself) can satisfy dependencies.
351#[cfg(feature = "fs")]
352pub struct OfflineDownloader;
353
354#[cfg(feature = "fs")]
355impl typst_kit::downloader::Downloader for OfflineDownloader {
356    fn stream(
357        &self,
358        _key: &dyn std::any::Any,
359        _url: &str,
360    ) -> std::io::Result<(Option<usize>, Box<dyn std::io::Read>)> {
361        Err(std::io::Error::new(
362            std::io::ErrorKind::NotFound,
363            "network access is disabled (offline mode)",
364        ))
365    }
366}
367
368#[cfg(feature = "fs")]
369impl FileLoader for SystemPackageLoader {
370    fn load(&self, id: FileId) -> FileResult<Bytes> {
371        match id.root() {
372            VirtualRoot::Project => Err(FileError::NotFound(PathBuf::from(
373                id.vpath().get_without_slash(),
374            ))),
375            VirtualRoot::Package(spec) => Ok(self.0.obtain(spec)?.load(id.vpath())?),
376        }
377    }
378}
379
380/// A failure while building a [`PackWorld`].
381#[derive(Debug, thiserror::Error)]
382pub enum PackWorldError {
383    #[error("pack is not usable: {0}")]
384    InvalidPack(String),
385    #[error("embedded font `{0}` could not be loaded")]
386    InvalidFont(String),
387}