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, LibraryExt};
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,ignore
148    /// # use typst_as_lib::TypstEngine;
149    /// static TEMPLATE: &str = include_str!("./templates/template.typ");
150    /// static FONT: &[u8] = include_bytes!("./fonts/texgyrecursor-regular.otf");
151    /// static TEMPLATE_ID: &str = "/template.typ";
152    /// // ...
153    /// let template_collection = TypstEngine::builder().fonts([FONT])
154    ///     .with_static_file_resolver([(TEMPLATE_ID, TEMPLATE)]).build();
155    /// // Struct that implements Into<Dict>.
156    /// let inputs = todo!();
157    /// let tracer = Default::default();
158    /// let doc = template_collection.compile_with_input(&mut tracer, TEMPLATE_ID, inputs)
159    ///     .expect("Typst error!");
160    /// ```
161    pub fn compile_with_input<F, D, Doc>(
162        &self,
163        main_source_id: F,
164        inputs: D,
165    ) -> Warned<Result<Doc, TypstAsLibError>>
166    where
167        F: IntoFileId,
168        D: Into<Dict>,
169        Doc: Document,
170    {
171        self.do_compile(main_source_id.into_file_id(), Some(inputs.into()))
172    }
173
174    /// Just call `typst::compile()`. Same as Self::compile_with_input but without the input
175    pub fn compile<F, Doc>(&self, main_source_id: F) -> Warned<Result<Doc, TypstAsLibError>>
176    where
177        F: IntoFileId,
178        Doc: Document,
179    {
180        self.do_compile(main_source_id.into_file_id(), None)
181    }
182}
183
184impl TypstEngine<TypstTemplateMainFile> {
185    /// Call `typst::compile()` with our template and a `Dict` as input, that will be availible
186    /// in a typst script with `#import sys: inputs`.
187    ///
188    /// Example:
189    ///
190    /// ```rust,ignore
191    /// # use typst_as_lib::TypstEngine;
192    /// static TEMPLATE: &str = include_str!("./templates/template.typ");
193    /// static FONT: &[u8] = include_bytes!("./fonts/texgyrecursor-regular.otf");
194    /// static TEMPLATE_ID: &str = "/template.typ";
195    /// // ...
196    /// let template_collection = TypstEngine::builder()
197    ///     .main_file(TEMPLATE).fonts([FONT]).build();
198    /// // Struct that implements Into<Dict>.
199    /// let inputs = todo!();
200    /// let tracer = Default::default();
201    /// let doc = template_collection.compile_with_input(&mut tracer, TEMPLATE_ID, inputs)
202    ///     .expect("Typst error!");
203    /// ```
204    pub fn compile_with_input<D, Doc>(&self, inputs: D) -> Warned<Result<Doc, TypstAsLibError>>
205    where
206        D: Into<Dict>,
207        Doc: Document,
208    {
209        let TypstTemplateMainFile { source_id } = self.template;
210        self.do_compile(source_id, Some(inputs.into()))
211    }
212
213    /// Just call `typst::compile()`. Same as Self::compile_with_input but without the input
214    pub fn compile<Doc>(&self) -> Warned<Result<Doc, TypstAsLibError>>
215    where
216        Doc: Document,
217    {
218        let TypstTemplateMainFile { source_id } = self.template;
219        self.do_compile(source_id, None)
220    }
221}
222
223pub struct TypstTemplateEngineBuilder<T = TypstTemplateCollection> {
224    template: T,
225    inject_location: Option<InjectLocation>,
226    file_resolvers: Vec<Box<dyn FileResolver + Send + Sync + 'static>>,
227    comemo_evict_max_age: Option<usize>,
228    fonts: Option<Vec<Font>>,
229    #[cfg(feature = "typst-kit-fonts")]
230    typst_kit_font_options: Option<typst_kit_options::TypstKitFontOptions>,
231}
232
233impl Default for TypstTemplateEngineBuilder {
234    fn default() -> Self {
235        Self {
236            template: TypstTemplateCollection,
237            inject_location: Default::default(),
238            file_resolvers: Default::default(),
239            comemo_evict_max_age: Some(0),
240            fonts: Default::default(),
241            #[cfg(feature = "typst-kit-fonts")]
242            typst_kit_font_options: None,
243        }
244    }
245}
246
247impl TypstTemplateEngineBuilder<TypstTemplateCollection> {
248    /// Declare a main_file that is used for each compilation as a starting point. This is optional.
249    pub fn main_file<S: IntoSource>(
250        self,
251        source: S,
252    ) -> TypstTemplateEngineBuilder<TypstTemplateMainFile> {
253        let source = source.into_source();
254        let source_id = source.id();
255        let template = TypstTemplateMainFile { source_id };
256        let TypstTemplateEngineBuilder {
257            inject_location,
258            mut file_resolvers,
259            comemo_evict_max_age,
260            fonts,
261            #[cfg(feature = "typst-kit-fonts")]
262            typst_kit_font_options,
263            ..
264        } = self;
265        file_resolvers.push(Box::new(MainSourceFileResolver::new(source)));
266        TypstTemplateEngineBuilder {
267            template,
268            inject_location,
269            file_resolvers,
270            comemo_evict_max_age,
271            fonts,
272            #[cfg(feature = "typst-kit-fonts")]
273            typst_kit_font_options,
274        }
275    }
276}
277
278impl<T> TypstTemplateEngineBuilder<T> {
279    /// Use other typst location for injected inputs
280    /// (instead of`#import sys: inputs`, where `sys` is the `module_name`
281    /// and `inputs` is the `value_name`).
282    pub fn custom_inject_location(
283        mut self,
284        module_name: &'static str,
285        value_name: &'static str,
286    ) -> Self {
287        self.inject_location = Some(InjectLocation {
288            module_name,
289            value_name,
290        });
291        self
292    }
293
294    /// Fonts
295    /// Accepts IntoIterator Items:
296    ///   - &[u8]
297    ///   - Vec<u8>
298    ///   - Bytes
299    ///   - Font
300    pub fn fonts<I, F>(mut self, fonts: I) -> Self
301    where
302        I: IntoIterator<Item = F>,
303        F: IntoFonts,
304    {
305        let fonts = fonts
306            .into_iter()
307            .flat_map(IntoFonts::into_fonts)
308            .collect::<Vec<_>>();
309        self.fonts = Some(fonts);
310        self
311    }
312
313    /// Use typst_kit::fonts::FontSearcher when looking up fonts
314    /// ```rust,no_run
315    /// # use typst_as_lib::TypstEngine;
316    /// let template = TypstEngine::builder()
317    ///     .search_fonts_with(Default::default())
318    ///     .with_static_file_resolver([("template.typ", &b""[..])])
319    ///     .build();
320    /// ```
321    #[cfg(feature = "typst-kit-fonts")]
322    pub fn search_fonts_with(mut self, options: typst_kit_options::TypstKitFontOptions) -> Self {
323        self.typst_kit_font_options = Some(options);
324        self
325    }
326
327    /// Add file resolver, that implements the `FileResolver`` trait to a vec of file resolvers.
328    /// When a `FileId`` needs to be resolved by Typst, the vec will be iterated over until
329    /// one file resolver returns a file.
330    pub fn add_file_resolver<F>(mut self, file_resolver: F) -> Self
331    where
332        F: FileResolver + Send + Sync + 'static,
333    {
334        self.file_resolvers.push(Box::new(file_resolver));
335        self
336    }
337
338    /// Adds the `StaticSourceFileResolver` to the file resolvers. It creates `HashMap`s for sources.
339    ///
340    /// `sources` The item of the IntoIterator can be of types:
341    ///   - `&str/String`, creating a detached Source (Has vpath `/main.typ`)
342    ///   - `(&str, &str/String)`, where &str is the absolute
343    ///     virtual path of the Source file.
344    ///   - `(typst::syntax::FileId, &str/String)`
345    ///   - `typst::syntax::Source`
346    ///
347    /// (`&str/String` is always the template file content)
348    pub fn with_static_source_file_resolver<IS, S>(self, sources: IS) -> Self
349    where
350        IS: IntoIterator<Item = S>,
351        S: IntoSource,
352    {
353        self.add_file_resolver(StaticSourceFileResolver::new(sources))
354    }
355
356    /// Adds the `StaticFileResolver` to the file resolvers. It creates `HashMap`s for binaries.
357    pub fn with_static_file_resolver<IB, F, B>(self, binaries: IB) -> Self
358    where
359        IB: IntoIterator<Item = (F, B)>,
360        F: IntoFileId,
361        B: IntoBytes,
362    {
363        self.add_file_resolver(StaticFileResolver::new(binaries))
364    }
365
366    /// Adds `FileSystemResolver` to the file resolvers, a resolver that can resolve
367    /// local files (when `package` is not set in `FileId`).
368    pub fn with_file_system_resolver<P>(self, root: P) -> Self
369    where
370        P: Into<PathBuf>,
371    {
372        self.add_file_resolver(FileSystemResolver::new(root.into()).into_cached())
373    }
374
375    pub fn comemo_evict_max_age(&mut self, comemo_evict_max_age: Option<usize>) -> &mut Self {
376        self.comemo_evict_max_age = comemo_evict_max_age;
377        self
378    }
379
380    #[cfg(all(feature = "packages", any(feature = "ureq", feature = "reqwest")))]
381    /// Adds `PackageResolver` to the file resolvers.
382    /// When `package` is set in `FileId`, it will download the package from the typst package
383    /// repository. It caches the results into `cache` (which is either in memory or cache folder (default)).
384    /// Example
385    /// ```rust,no_run
386    /// # use typst_as_lib::TypstEngine;
387    /// let template = TypstEngine::builder()
388    ///     .with_package_file_resolver()
389    ///     .build();
390    /// ```
391    pub fn with_package_file_resolver(self) -> Self {
392        use package_resolver::PackageResolver;
393        let file_resolver = PackageResolver::builder()
394            .with_file_system_cache()
395            .build()
396            .into_cached();
397        self.add_file_resolver(file_resolver)
398    }
399
400    pub fn build(self) -> TypstEngine<T> {
401        let TypstTemplateEngineBuilder {
402            template,
403            inject_location,
404            file_resolvers,
405            comemo_evict_max_age,
406            fonts,
407            #[cfg(feature = "typst-kit-fonts")]
408            typst_kit_font_options,
409        } = self;
410
411        let mut book = FontBook::new();
412        if let Some(fonts) = &fonts {
413            for f in fonts {
414                book.push(f.info().clone());
415            }
416        }
417
418        #[allow(unused_mut)]
419        let mut fonts: Vec<_> = fonts.into_iter().flatten().map(FontEnum::Font).collect();
420
421        #[cfg(feature = "typst-kit-fonts")]
422        if let Some(typst_kit_font_options) = typst_kit_font_options {
423            let typst_kit_options::TypstKitFontOptions {
424                include_system_fonts,
425                include_dirs,
426                #[cfg(feature = "typst-kit-embed-fonts")]
427                include_embedded_fonts,
428            } = typst_kit_font_options;
429            let mut searcher = typst_kit::fonts::Fonts::searcher();
430            #[cfg(feature = "typst-kit-embed-fonts")]
431            searcher.include_embedded_fonts(include_embedded_fonts);
432            let typst_kit::fonts::Fonts {
433                book: typst_kit_book,
434                fonts: typst_kit_fonts,
435            } = searcher
436                .include_system_fonts(include_system_fonts)
437                .search_with(include_dirs);
438            let len = typst_kit_fonts.len();
439            let font_slots = typst_kit_fonts.into_iter().map(FontEnum::FontSlot);
440            if fonts.is_empty() {
441                book = typst_kit_book;
442                fonts = font_slots.collect();
443            } else {
444                for i in 0..len {
445                    let Some(info) = typst_kit_book.info(i) else {
446                        break;
447                    };
448                    book.push(info.clone());
449                }
450                fonts.extend(font_slots);
451            }
452        }
453
454        #[cfg(not(feature = "typst-html"))]
455        let library = typst::Library::builder().build();
456
457        #[cfg(feature = "typst-html")]
458        let library = typst::Library::builder()
459            .with_features([typst::Feature::Html].into_iter().collect())
460            .build();
461
462        TypstEngine {
463            template,
464            inject_location,
465            file_resolvers,
466            comemo_evict_max_age,
467            library: LazyHash::new(library),
468            book: LazyHash::new(book),
469            fonts,
470        }
471    }
472}
473
474struct TypstWorld<'a> {
475    library: Cow<'a, LazyHash<Library>>,
476    main_source_id: FileId,
477    now: DateTime<Utc>,
478    book: &'a LazyHash<FontBook>,
479    file_resolvers: &'a [Box<dyn FileResolver + Send + Sync + 'static>],
480    fonts: &'a [FontEnum],
481}
482
483impl typst::World for TypstWorld<'_> {
484    fn library(&self) -> &LazyHash<Library> {
485        self.library.as_ref()
486    }
487
488    fn book(&self) -> &LazyHash<FontBook> {
489        self.book
490    }
491
492    fn main(&self) -> FileId {
493        self.main_source_id
494    }
495
496    fn source(&self, id: FileId) -> FileResult<Source> {
497        let Self { file_resolvers, .. } = *self;
498        let mut last_error = not_found(id);
499        for file_resolver in file_resolvers {
500            match file_resolver.resolve_source(id) {
501                Ok(source) => return Ok(source.into_owned()),
502                Err(error) => last_error = error,
503            }
504        }
505        Err(last_error)
506    }
507
508    fn file(&self, id: FileId) -> FileResult<Bytes> {
509        let Self { file_resolvers, .. } = *self;
510        let mut last_error = not_found(id);
511        for file_resolver in file_resolvers {
512            match file_resolver.resolve_binary(id) {
513                Ok(file) => return Ok(file.into_owned()),
514                Err(error) => last_error = error,
515            }
516        }
517        Err(last_error)
518    }
519
520    fn font(&self, id: usize) -> Option<Font> {
521        self.fonts[id].get()
522    }
523
524    fn today(&self, offset: Option<i64>) -> Option<Datetime> {
525        let mut now = self.now;
526        if let Some(offset) = offset {
527            now += Duration::hours(offset);
528        }
529        let date = now.date_naive();
530        let year = date.year();
531        let month = (date.month0() + 1) as u8;
532        let day = (date.day0() + 1) as u8;
533        Datetime::from_ymd(year, month, day)
534    }
535}
536
537#[derive(Debug, Clone)]
538struct InjectLocation {
539    module_name: &'static str,
540    value_name: &'static str,
541}
542
543#[derive(Debug, Clone, Error)]
544pub enum TypstAsLibError {
545    #[error("Typst source error: {0:?}")]
546    TypstSource(EcoVec<SourceDiagnostic>),
547    #[error("Typst file error: {0}")]
548    TypstFile(#[from] FileError),
549    #[error("Source file does not exist in collection: {0:?}")]
550    MainSourceFileDoesNotExist(FileId),
551    #[error("Typst hinted String: {0:?}")]
552    HintedString(HintedString),
553    #[error("Unspecified: {0}!")]
554    Unspecified(ecow::EcoString),
555}
556
557impl From<HintedString> for TypstAsLibError {
558    fn from(value: HintedString) -> Self {
559        TypstAsLibError::HintedString(value)
560    }
561}
562
563impl From<EcoVec<SourceDiagnostic>> for TypstAsLibError {
564    fn from(value: EcoVec<SourceDiagnostic>) -> Self {
565        TypstAsLibError::TypstSource(value)
566    }
567}
568
569#[derive(Debug)]
570pub enum FontEnum {
571    Font(Font),
572    #[cfg(feature = "typst-kit-fonts")]
573    FontSlot(typst_kit::fonts::FontSlot),
574}
575
576impl FontEnum {
577    pub fn get(&self) -> Option<Font> {
578        match self {
579            FontEnum::Font(font) => Some(font.clone()),
580            #[cfg(feature = "typst-kit-fonts")]
581            FontEnum::FontSlot(font_slot) => font_slot.get(),
582        }
583    }
584}