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