mdbook_typst_math/
lib.rs

1use std::path::PathBuf;
2
3use anyhow::anyhow;
4use mdbook::book::{Book, Chapter};
5use mdbook::errors::Result;
6use mdbook::preprocess::{Preprocessor, PreprocessorContext};
7use mdbook::BookItem;
8use pulldown_cmark::{Event, Options, Parser};
9
10mod compiler;
11use compiler::Compiler;
12use typst::foundations::Bytes;
13use typst::text::{Font, FontInfo};
14
15/// Options that are passed to the compile step
16pub struct TypstProcessorOptions {
17    /// preamble to be added before each content
18    ///
19    /// This is used as fallback if the following options are not set
20    pub preamble: String,
21    /// preamble to be added before each inline math
22    pub inline_preamble: Option<String>,
23    /// preamble to be added before each display math
24    pub display_preamble: Option<String>,
25}
26
27pub struct TypstProcessor;
28
29impl Preprocessor for TypstProcessor {
30    fn name(&self) -> &str {
31        "typst-math"
32    }
33
34    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
35        let config = ctx.config.get_preprocessor(self.name());
36        let mut compiler = Compiler::new();
37
38        // Set options
39        let mut opts = TypstProcessorOptions {
40            preamble: String::from("#set page(width: auto, height: auto, margin: 0.5em)"),
41            inline_preamble: None,
42            display_preamble: None,
43        };
44        if let Some(preamble) = config.and_then(|c| c.get("preamble")) {
45            opts.preamble = preamble
46                .as_str()
47                .map(String::from)
48                .expect("preamble must be a string");
49        }
50        if let Some(inline_preamble) = config.and_then(|c| c.get("inline_preamble")) {
51            opts.inline_preamble = Some(
52                inline_preamble
53                    .as_str()
54                    .map(String::from)
55                    .expect("inline_preamble must be a string"),
56            );
57        }
58        if let Some(display_preamble) = config.and_then(|c| c.get("display_preamble")) {
59            opts.display_preamble = Some(
60                display_preamble
61                    .as_str()
62                    .map(String::from)
63                    .expect("display_preamble must be a string"),
64            );
65        }
66
67        let mut db = fontdb::Database::new();
68        // Load fonts from the config
69        if let Some(fonts) = config.and_then(|c| c.get("fonts")) {
70            if let Some(fonts) = fonts.as_array() {
71                for font in fonts {
72                    let font = font.as_str().unwrap();
73                    db.load_fonts_dir(font);
74                }
75            };
76            if let Some(font) = fonts.as_str() {
77                db.load_fonts_dir(font);
78            };
79        }
80        // Load system fonts, lower priority
81        db.load_system_fonts();
82
83        // Add all fonts in db to the compiler
84        for face in db.faces() {
85            let info = db
86                .with_face_data(face.id, FontInfo::new)
87                .expect("Failed to load font info");
88            if let Some(info) = info {
89                compiler.book.update(|book| book.push(info));
90                if let Some(font) = match &face.source {
91                    fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => {
92                        let bytes = std::fs::read(path).expect("Failed to read font file");
93                        Font::new(Bytes::from(bytes), face.index)
94                    }
95                    fontdb::Source::Binary(data) => {
96                        Font::new(Bytes::from(data.as_ref().as_ref()), face.index)
97                    }
98                } {
99                    compiler.fonts.push(font);
100                }
101            }
102        }
103
104        #[cfg(feature = "embed-fonts")]
105        {
106            // Load typst embedded fonts, lowest priority
107            for data in typst_assets::fonts() {
108                let buffer = Bytes::from_static(data);
109                for font in Font::iter(buffer) {
110                    compiler.book.update(|book| book.push(font.info().clone()));
111                    compiler.fonts.push(font);
112                }
113            }
114        }
115
116        // Set the cache dir
117        if let Some(cache) = config.and_then(|c| c.get("cache")) {
118            compiler.cache = cache
119                .as_str()
120                .map(PathBuf::from)
121                .expect("cache dir must be a string");
122        }
123
124        // record if any errors occurred
125        let mut res = None;
126
127        book.for_each_mut(|item| {
128            if let Some(Err(_)) = res {
129                return;
130            }
131
132            if let BookItem::Chapter(ref mut chapter) = *item {
133                res = Some(self.convert_typst(chapter, &compiler, &opts).map(|c| {
134                    chapter.content = c;
135                }))
136            }
137        });
138
139        res.unwrap_or(Ok(())).map(|_| book)
140    }
141
142    fn supports_renderer(&self, renderer: &str) -> bool {
143        renderer == "html"
144    }
145}
146
147impl TypstProcessor {
148    fn convert_typst(
149        &self,
150        chapter: &mut Chapter,
151        compiler: &Compiler,
152        opts: &TypstProcessorOptions,
153    ) -> Result<String> {
154        let mut typst_blocks = Vec::new();
155
156        let mut pulldown_cmark_opts = Options::empty();
157        pulldown_cmark_opts.insert(Options::ENABLE_TABLES);
158        pulldown_cmark_opts.insert(Options::ENABLE_FOOTNOTES);
159        pulldown_cmark_opts.insert(Options::ENABLE_STRIKETHROUGH);
160        pulldown_cmark_opts.insert(Options::ENABLE_TASKLISTS);
161        pulldown_cmark_opts.insert(Options::ENABLE_MATH);
162
163        let parser = Parser::new_ext(&chapter.content, pulldown_cmark_opts);
164        for (e, span) in parser.into_offset_iter() {
165            if let Event::InlineMath(math_content) = e {
166                typst_blocks.push((
167                    span,
168                    format!(
169                        "{}\n${math_content}$",
170                        opts.inline_preamble.as_ref().unwrap_or(&opts.preamble)
171                    ),
172                    true,
173                ))
174            } else if let Event::DisplayMath(math_content) = e {
175                let math_content = math_content.trim();
176                typst_blocks.push((
177                    span,
178                    format!(
179                        "{}\n$ {math_content} $",
180                        opts.display_preamble.as_ref().unwrap_or(&opts.preamble)
181                    ),
182                    false,
183                ))
184            }
185        }
186
187        let mut content = chapter.content.to_string();
188
189        for (span, block, inline) in typst_blocks.iter().rev() {
190            let pre_content = &content[0..span.start];
191            let post_content = &content[span.end..];
192
193            let svg = compiler.render(block.clone()).map_err(|e| anyhow!(e))?;
194
195            content = match inline {
196                true => format!(
197                    "{}<span class=\"typst-inline\">{}</span>{}",
198                    pre_content, svg, post_content
199                ),
200                false => format!(
201                    "{}<div class=\"typst-display\">{}</div>{}",
202                    pre_content, svg, post_content
203                ),
204            };
205        }
206
207        Ok(content)
208    }
209}