typst_as_lib/
lib.rs

1// Inspired by https://github.com/tfachmann/typst-as-library/blob/main/src/lib.rs
2use std::borrow::Cow;
3use std::ops::Deref;
4use std::path::PathBuf;
5
6use cached_file_resolver::IntoCachedFileResolver;
7use chrono::{DateTime, Datelike, Duration, Utc};
8use conversions::{IntoBytes, IntoFileId, IntoFonts, IntoSource};
9use ecow::EcoVec;
10use file_resolver::{
11    FileResolver, FileSystemResolver, MainSourceFileResolver, StaticFileResolver,
12    StaticSourceFileResolver,
13};
14use thiserror::Error;
15use typst::diag::{FileError, FileResult, HintedString, SourceDiagnostic, Warned};
16use typst::foundations::{Bytes, Datetime, Dict, Module, Scope, Value};
17use typst::syntax::{FileId, Source};
18use typst::text::{Font, FontBook};
19use typst::utils::LazyHash;
20use typst::{Document, Library};
21use util::not_found;
22
23pub mod cached_file_resolver;
24pub mod conversions;
25pub mod file_resolver;
26pub(crate) mod util;
27
28#[cfg(all(feature = "packages", any(feature = "ureq", feature = "reqwest")))]
29pub mod package_resolver;
30
31#[cfg(feature = "typst-kit-fonts")]
32pub mod typst_kit_options;
33
34pub struct TypstEngine<T = TypstTemplateCollection> {
35    template: T,
36    book: LazyHash<FontBook>,
37    inject_location: Option<InjectLocation>,
38    file_resolvers: Vec<Box<dyn FileResolver + Send + Sync + 'static>>,
39    library: LazyHash<Library>,
40    comemo_evict_max_age: Option<usize>,
41    fonts: Vec<FontEnum>,
42}
43
44#[derive(Debug, Clone, Copy)]
45pub struct TypstTemplateCollection;
46
47#[derive(Debug, Clone, Copy)]
48pub struct TypstTemplateMainFile {
49    source_id: FileId,
50}
51
52impl<T> TypstEngine<T> {
53    fn do_compile<Doc>(
54        &self,
55        main_source_id: FileId,
56        inputs: Option<Dict>,
57    ) -> Warned<Result<Doc, TypstAsLibError>>
58    where
59        Doc: Document,
60    {
61        let library = if let Some(inputs) = inputs {
62            let lib = self.create_injected_library(inputs);
63            match lib {
64                Ok(lib) => Cow::Owned(lib),
65                Err(err) => {
66                    return Warned {
67                        output: Err(err),
68                        warnings: Default::default(),
69                    };
70                }
71            }
72        } else {
73            Cow::Borrowed(&self.library)
74        };
75        let world = TypstWorld {
76            main_source_id,
77            library,
78            now: Utc::now(),
79            file_resolvers: &self.file_resolvers,
80            book: &self.book,
81            fonts: &self.fonts,
82        };
83        let Warned { output, warnings } = typst::compile(&world);
84
85        if let Some(comemo_evict_max_age) = self.comemo_evict_max_age {
86            comemo::evict(comemo_evict_max_age);
87        }
88
89        Warned {
90            output: output.map_err(Into::into),
91            warnings,
92        }
93    }
94
95    fn create_injected_library<D>(&self, input: D) -> Result<LazyHash<Library>, TypstAsLibError>
96    where
97        D: Into<Dict>,
98    {
99        let Self {
100            inject_location,
101            library,
102            ..
103        } = self;
104        let mut lib = library.deref().clone();
105        let (module_name, value_name) = if let Some(InjectLocation {
106            module_name,
107            value_name,
108        }) = inject_location
109        {
110            (*module_name, *value_name)
111        } else {
112            ("sys", "inputs")
113        };
114        {
115            let global = lib.global.scope_mut();
116            let mut scope = Scope::new();
117            scope.define(value_name, input.into());
118            if let Some(value) = global.get_mut(module_name) {
119                let value = value.write().map_err(TypstAsLibError::Unspecified)?;
120                if let Value::Module(module) = value {
121                    *module.scope_mut() = scope;
122                } else {
123                    let module = Module::new(module_name, scope);
124                    *value = Value::Module(module);
125                }
126            } else {
127                let module = Module::new(module_name, scope);
128                global.define(module_name, module);
129            }
130        }
131        Ok(LazyHash::new(lib))
132    }
133}
134
135impl TypstEngine<TypstTemplateCollection> {
136    pub fn builder() -> TypstTemplateEngineBuilder {
137        TypstTemplateEngineBuilder::default()
138    }
139}
140
141impl TypstEngine<TypstTemplateCollection> {
142    /// Call `typst::compile()` with our template and a `Dict` as input, that will be availible
143    /// in a typst script with `#import sys: inputs`.
144    ///
145    /// Example:
146    ///
147    /// ```rust
148    /// static TEMPLATE: &str = include_str!("./templates/template.typ");
149    /// static FONT: &[u8] = include_bytes!("./fonts/texgyrecursor-regular.otf");
150    /// static TEMPLATE_ID: &str = "/template.typ";
151    /// // ...
152    /// let template_collection = TypstEngine::builder().fonts([FONT])
153    ///     .with_static_file_resolver([(TEMPLATE_ID, TEMPLATE)]).build();
154    /// // Struct that implements Into<Dict>.
155    /// let inputs = todo!();
156    /// let tracer = Default::default();
157    /// let doc = template_collection.compile_with_input(&mut tracer, TEMPLATE_ID, inputs)
158    ///     .expect("Typst error!");
159    /// ```
160    pub fn compile_with_input<F, D, Doc>(
161        &self,
162        main_source_id: F,
163        inputs: D,
164    ) -> Warned<Result<Doc, TypstAsLibError>>
165    where
166        F: IntoFileId,
167        D: Into<Dict>,
168        Doc: Document,
169    {
170        self.do_compile(main_source_id.into_file_id(), Some(inputs.into()))
171    }
172
173    /// Just call `typst::compile()`. Same as Self::compile_with_input but without the input
174    pub fn compile<F, Doc>(&self, main_source_id: F) -> Warned<Result<Doc, TypstAsLibError>>
175    where
176        F: IntoFileId,
177        Doc: Document,
178    {
179        self.do_compile(main_source_id.into_file_id(), None)
180    }
181}
182
183impl TypstEngine<TypstTemplateMainFile> {
184    /// Call `typst::compile()` with our template and a `Dict` as input, that will be availible
185    /// in a typst script with `#import sys: inputs`.
186    ///
187    /// Example:
188    ///
189    /// ```rust
190    /// static TEMPLATE: &str = include_str!("./templates/template.typ");
191    /// static FONT: &[u8] = include_bytes!("./fonts/texgyrecursor-regular.otf");
192    /// static TEMPLATE_ID: &str = "/template.typ";
193    /// // ...
194    /// let template_collection = TypstEngine::builder()
195    ///     .main_file(TEMPLATE).fonts([FONT]).build();
196    /// // Struct that implements Into<Dict>.
197    /// let inputs = todo!();
198    /// let tracer = Default::default();
199    /// let doc = template_collection.compile_with_input(&mut tracer, TEMPLATE_ID, inputs)
200    ///     .expect("Typst error!");
201    /// ```
202    pub fn compile_with_input<D, Doc>(&self, inputs: D) -> Warned<Result<Doc, TypstAsLibError>>
203    where
204        D: Into<Dict>,
205        Doc: Document,
206    {
207        let TypstTemplateMainFile { source_id } = self.template;
208        self.do_compile(source_id, Some(inputs.into()))
209    }
210
211    /// Just call `typst::compile()`. Same as Self::compile_with_input but without the input
212    pub fn compile<Doc>(&self) -> Warned<Result<Doc, TypstAsLibError>>
213    where
214        Doc: Document,
215    {
216        let TypstTemplateMainFile { source_id } = self.template;
217        self.do_compile(source_id, None)
218    }
219}
220
221pub struct TypstTemplateEngineBuilder<T = TypstTemplateCollection> {
222    template: T,
223    inject_location: Option<InjectLocation>,
224    file_resolvers: Vec<Box<dyn FileResolver + Send + Sync + 'static>>,
225    comemo_evict_max_age: Option<usize>,
226    fonts: Option<Vec<Font>>,
227    #[cfg(feature = "typst-kit-fonts")]
228    typst_kit_font_options: Option<typst_kit_options::TypstKitFontOptions>,
229}
230
231impl Default for TypstTemplateEngineBuilder {
232    fn default() -> Self {
233        Self {
234            template: TypstTemplateCollection,
235            inject_location: Default::default(),
236            file_resolvers: Default::default(),
237            comemo_evict_max_age: Some(0),
238            fonts: Default::default(),
239            #[cfg(feature = "typst-kit-fonts")]
240            typst_kit_font_options: None,
241        }
242    }
243}
244
245impl TypstTemplateEngineBuilder<TypstTemplateCollection> {
246    /// Declare a main_file that is used for each compilation as a starting point. This is optional.
247    pub fn main_file<S: IntoSource>(
248        self,
249        source: S,
250    ) -> TypstTemplateEngineBuilder<TypstTemplateMainFile> {
251        let source = source.into_source();
252        let source_id = source.id();
253        let template = TypstTemplateMainFile { source_id };
254        let TypstTemplateEngineBuilder {
255            inject_location,
256            mut file_resolvers,
257            comemo_evict_max_age,
258            fonts,
259            #[cfg(feature = "typst-kit-fonts")]
260            typst_kit_font_options,
261            ..
262        } = self;
263        file_resolvers.push(Box::new(MainSourceFileResolver::new(source)));
264        TypstTemplateEngineBuilder {
265            template,
266            inject_location,
267            file_resolvers,
268            comemo_evict_max_age,
269            fonts,
270            #[cfg(feature = "typst-kit-fonts")]
271            typst_kit_font_options,
272        }
273    }
274}
275
276impl<T> TypstTemplateEngineBuilder<T> {
277    /// Use other typst location for injected inputs
278    /// (instead of`#import sys: inputs`, where `sys` is the `module_name`
279    /// and `inputs` is the `value_name`).
280    pub fn custom_inject_location(
281        mut self,
282        module_name: &'static str,
283        value_name: &'static str,
284    ) -> Self {
285        self.inject_location = Some(InjectLocation {
286            module_name,
287            value_name,
288        });
289        self
290    }
291
292    /// Fonts
293    /// Accepts IntoIterator Items:
294    ///   - &[u8]
295    ///   - Vec<u8>
296    ///   - Bytes
297    ///   - Font
298    pub fn fonts<I, F>(mut self, fonts: I) -> Self
299    where
300        I: IntoIterator<Item = F>,
301        F: IntoFonts,
302    {
303        let fonts = fonts
304            .into_iter()
305            .flat_map(IntoFonts::into_fonts)
306            .collect::<Vec<_>>();
307        self.fonts = Some(fonts);
308        self
309    }
310
311    /// Use typst_kit::fonts::FontSearcher when looking up fonts
312    /// ```rust
313    /// // ...
314    ///
315    /// let template = TypstEngine::builder()
316    ///     .search_fonts_with(Default::default())
317    ///     .with_static_file_resolver([TEMPLATE], [])
318    ///     .build();
319    /// ```
320    #[cfg(feature = "typst-kit-fonts")]
321    pub fn search_fonts_with(mut self, options: typst_kit_options::TypstKitFontOptions) -> Self {
322        self.typst_kit_font_options = Some(options);
323        self
324    }
325
326    /// Add file resolver, that implements the `FileResolver`` trait to a vec of file resolvers.
327    /// When a `FileId`` needs to be resolved by Typst, the vec will be iterated over until
328    /// one file resolver returns a file.
329    pub fn add_file_resolver<F>(mut self, file_resolver: F) -> Self
330    where
331        F: FileResolver + Send + Sync + 'static,
332    {
333        self.file_resolvers.push(Box::new(file_resolver));
334        self
335    }
336
337    /// Adds the `StaticSourceFileResolver` to the file resolvers. It creates `HashMap`s for sources.
338    ///
339    /// `sources` The item of the IntoIterator can be of types:
340    ///   - `&str/String`, creating a detached Source (Has vpath `/main.typ`)
341    ///   - `(&str, &str/String)`, where &str is the absolute
342    ///     virtual path of the Source file.
343    ///   - `(typst::syntax::FileId, &str/String)`
344    ///   - `typst::syntax::Source`
345    ///
346    /// (`&str/String` is always the template file content)
347    pub fn with_static_source_file_resolver<IS, S>(self, sources: IS) -> Self
348    where
349        IS: IntoIterator<Item = S>,
350        S: IntoSource,
351    {
352        self.add_file_resolver(StaticSourceFileResolver::new(sources))
353    }
354
355    /// Adds the `StaticFileResolver` to the file resolvers. It creates `HashMap`s for binaries.
356    pub fn with_static_file_resolver<IB, F, B>(self, binaries: IB) -> Self
357    where
358        IB: IntoIterator<Item = (F, B)>,
359        F: IntoFileId,
360        B: IntoBytes,
361    {
362        self.add_file_resolver(StaticFileResolver::new(binaries))
363    }
364
365    /// Adds `FileSystemResolver` to the file resolvers, a resolver that can resolve
366    /// local files (when `package` is not set in `FileId`).
367    pub fn with_file_system_resolver<P>(self, root: P) -> Self
368    where
369        P: Into<PathBuf>,
370    {
371        self.add_file_resolver(FileSystemResolver::new(root.into()).into_cached())
372    }
373
374    pub fn comemo_evict_max_age(&mut self, comemo_evict_max_age: Option<usize>) -> &mut Self {
375        self.comemo_evict_max_age = comemo_evict_max_age;
376        self
377    }
378
379    #[cfg(all(feature = "packages", any(feature = "ureq", feature = "reqwest")))]
380    /// Adds `PackageResolver` to the file resolvers.
381    /// When `package` is set in `FileId`, it will download the package from the typst package
382    /// repository. It caches the results into `cache` (which is either in memory or cache folder (default)).
383    /// Example
384    /// ```rust
385    ///     let template = TypstTemplateCollection::new(vec![font])
386    ///         .with_package_file_resolver(None);
387    /// ```
388    pub fn with_package_file_resolver(self) -> Self {
389        use package_resolver::PackageResolver;
390        let file_resolver = PackageResolver::builder()
391            .with_file_system_cache()
392            .build()
393            .into_cached();
394        self.add_file_resolver(file_resolver)
395    }
396
397    pub fn build(self) -> TypstEngine<T> {
398        let TypstTemplateEngineBuilder {
399            template,
400            inject_location,
401            file_resolvers,
402            comemo_evict_max_age,
403            fonts,
404            #[cfg(feature = "typst-kit-fonts")]
405            typst_kit_font_options,
406        } = self;
407
408        let mut book = FontBook::new();
409        if let Some(fonts) = &fonts {
410            for f in fonts {
411                book.push(f.info().clone());
412            }
413        }
414
415        #[allow(unused_mut)]
416        let mut fonts: Vec<_> = fonts.into_iter().flatten().map(FontEnum::Font).collect();
417
418        #[cfg(feature = "typst-kit-fonts")]
419        if let Some(typst_kit_font_options) = typst_kit_font_options {
420            let typst_kit_options::TypstKitFontOptions {
421                include_system_fonts,
422                include_dirs,
423                #[cfg(feature = "typst-kit-embed-fonts")]
424                include_embedded_fonts,
425            } = typst_kit_font_options;
426            let mut searcher = typst_kit::fonts::Fonts::searcher();
427            #[cfg(feature = "typst-kit-embed-fonts")]
428            searcher.include_embedded_fonts(include_embedded_fonts);
429            let typst_kit::fonts::Fonts {
430                book: typst_kit_book,
431                fonts: typst_kit_fonts,
432            } = searcher
433                .include_system_fonts(include_system_fonts)
434                .search_with(include_dirs);
435            let len = typst_kit_fonts.len();
436            let font_slots = typst_kit_fonts.into_iter().map(FontEnum::FontSlot);
437            if fonts.is_empty() {
438                book = typst_kit_book;
439                fonts = font_slots.collect();
440            } else {
441                for i in 0..len {
442                    let Some(info) = typst_kit_book.info(i) else {
443                        break;
444                    };
445                    book.push(info.clone());
446                }
447                fonts.extend(font_slots);
448            }
449        }
450
451        #[cfg(not(feature = "typst-html"))]
452        let library = Default::default();
453
454        #[cfg(feature = "typst-html")]
455        let library = typst::Library::builder()
456            .with_features([typst::Feature::Html].into_iter().collect())
457            .build();
458
459        TypstEngine {
460            template,
461            inject_location,
462            file_resolvers,
463            comemo_evict_max_age,
464            library: LazyHash::new(library),
465            book: LazyHash::new(book),
466            fonts,
467        }
468    }
469}
470
471struct TypstWorld<'a> {
472    library: Cow<'a, LazyHash<Library>>,
473    main_source_id: FileId,
474    now: DateTime<Utc>,
475    book: &'a LazyHash<FontBook>,
476    file_resolvers: &'a [Box<dyn FileResolver + Send + Sync + 'static>],
477    fonts: &'a [FontEnum],
478}
479
480impl typst::World for TypstWorld<'_> {
481    fn library(&self) -> &LazyHash<Library> {
482        self.library.as_ref()
483    }
484
485    fn book(&self) -> &LazyHash<FontBook> {
486        self.book
487    }
488
489    fn main(&self) -> FileId {
490        self.main_source_id
491    }
492
493    fn source(&self, id: FileId) -> FileResult<Source> {
494        let Self { file_resolvers, .. } = *self;
495        let mut last_error = not_found(id);
496        for file_resolver in file_resolvers {
497            match file_resolver.resolve_source(id) {
498                Ok(source) => return Ok(source.into_owned()),
499                Err(error) => last_error = error,
500            }
501        }
502        Err(last_error)
503    }
504
505    fn file(&self, id: FileId) -> FileResult<Bytes> {
506        let Self { file_resolvers, .. } = *self;
507        let mut last_error = not_found(id);
508        for file_resolver in file_resolvers {
509            match file_resolver.resolve_binary(id) {
510                Ok(file) => return Ok(file.into_owned()),
511                Err(error) => last_error = error,
512            }
513        }
514        Err(last_error)
515    }
516
517    fn font(&self, id: usize) -> Option<Font> {
518        self.fonts[id].get()
519    }
520
521    fn today(&self, offset: Option<i64>) -> Option<Datetime> {
522        let mut now = self.now;
523        if let Some(offset) = offset {
524            now += Duration::hours(offset);
525        }
526        let date = now.date_naive();
527        let year = date.year();
528        let month = (date.month0() + 1) as u8;
529        let day = (date.day0() + 1) as u8;
530        Datetime::from_ymd(year, month, day)
531    }
532}
533
534#[derive(Debug, Clone)]
535struct InjectLocation {
536    module_name: &'static str,
537    value_name: &'static str,
538}
539
540#[derive(Debug, Clone, Error)]
541pub enum TypstAsLibError {
542    #[error("Typst source error: {0:?}")]
543    TypstSource(EcoVec<SourceDiagnostic>),
544    #[error("Typst file error: {0}")]
545    TypstFile(#[from] FileError),
546    #[error("Source file does not exist in collection: {0:?}")]
547    MainSourceFileDoesNotExist(FileId),
548    #[error("Typst hinted String: {0:?}")]
549    HintedString(HintedString),
550    #[error("Unspecified: {0}!")]
551    Unspecified(ecow::EcoString),
552}
553
554impl From<HintedString> for TypstAsLibError {
555    fn from(value: HintedString) -> Self {
556        TypstAsLibError::HintedString(value)
557    }
558}
559
560impl From<EcoVec<SourceDiagnostic>> for TypstAsLibError {
561    fn from(value: EcoVec<SourceDiagnostic>) -> Self {
562        TypstAsLibError::TypstSource(value)
563    }
564}
565
566#[derive(Debug)]
567pub enum FontEnum {
568    Font(Font),
569    #[cfg(feature = "typst-kit-fonts")]
570    FontSlot(typst_kit::fonts::FontSlot),
571}
572
573impl FontEnum {
574    pub fn get(&self) -> Option<Font> {
575        match self {
576            FontEnum::Font(font) => Some(font.clone()),
577            #[cfg(feature = "typst-kit-fonts")]
578            FontEnum::FontSlot(font_slot) => font_slot.get(),
579        }
580    }
581}