mdbook_typst_math/
lib.rs

1//! mdbook-typst-math - An mdbook preprocessor to render math using Typst
2//!
3//! This crate provides a preprocessor for mdbook that converts LaTeX-style
4//! math blocks into SVG images rendered by Typst.
5//!
6//! # Usage
7//!
8//! Add the preprocessor to your `book.toml`:
9//!
10//! ```toml
11//! [preprocessor.typst-math]
12//! ```
13//!
14//! # Configuration
15//!
16//! The preprocessor supports the following configuration options:
17//!
18//! - `preamble`: Typst code to prepend to all math blocks
19//! - `inline_preamble`: Typst code to prepend to inline math blocks
20//! - `display_preamble`: Typst code to prepend to display math blocks
21//! - `fonts`: List of font directories to load
22//! - `cache`: Directory for caching downloaded packages
23
24use std::path::PathBuf;
25
26use anyhow::anyhow;
27use mdbook_preprocessor::book::{Book, BookItem, Chapter};
28use mdbook_preprocessor::errors::Result;
29use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
30use pulldown_cmark::{Event, Options, Parser};
31use serde::Deserialize;
32
33mod compiler;
34use compiler::{CompileError, Compiler};
35use typst::foundations::Bytes;
36use typst::text::{Font, FontInfo};
37
38/// Options that control how Typst renders math blocks.
39///
40/// These options allow customization of the Typst preamble used for
41/// inline and display math rendering.
42pub struct TypstProcessorOptions {
43    /// Default preamble added before each math block.
44    ///
45    /// This is used as a fallback if `inline_preamble` or `display_preamble`
46    /// is not set. The default value sets up an auto-sized page with minimal margins.
47    pub preamble: String,
48    /// Optional preamble specifically for inline math (`$...$`).
49    ///
50    /// If `None`, the default `preamble` is used instead.
51    pub inline_preamble: Option<String>,
52    /// Optional preamble specifically for display math (`$$...$$`).
53    ///
54    /// If `None`, the default `preamble` is used instead.
55    pub display_preamble: Option<String>,
56}
57
58/// Represents font configuration that accepts either a single string or an array.
59///
60/// This allows users to specify fonts in `book.toml` as either:
61/// - `fonts = "path/to/fonts"`
62/// - `fonts = ["path1", "path2"]`
63#[derive(Debug, Clone, Deserialize)]
64#[serde(untagged)]
65enum FontsConfig {
66    Single(String),
67    Multiple(Vec<String>),
68}
69
70impl FontsConfig {
71    fn into_vec(self) -> Vec<String> {
72        match self {
73            FontsConfig::Single(s) => vec![s],
74            FontsConfig::Multiple(v) => v,
75        }
76    }
77}
78
79/// Configuration for the typst-math preprocessor from book.toml
80#[derive(Debug, Clone, Default, Deserialize)]
81#[serde(default)]
82struct TypstMathConfig {
83    preamble: Option<String>,
84    inline_preamble: Option<String>,
85    display_preamble: Option<String>,
86    fonts: Option<FontsConfig>,
87    cache: Option<String>,
88}
89
90/// The main preprocessor that converts math blocks to Typst-rendered SVGs.
91///
92/// This preprocessor scans markdown content for inline math (`$...$`) and
93/// display math (`$$...$$`) blocks, renders them using Typst, and replaces
94/// them with SVG images wrapped in appropriate HTML elements.
95///
96/// # Example
97///
98/// ```ignore
99/// use mdbook_typst_math::TypstProcessor;
100/// use mdbook_preprocessor::Preprocessor;
101///
102/// let processor = TypstProcessor;
103/// assert_eq!(processor.name(), "typst-math");
104/// ```
105pub struct TypstProcessor;
106
107impl Preprocessor for TypstProcessor {
108    fn name(&self) -> &str {
109        "typst-math"
110    }
111
112    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
113        let config: TypstMathConfig = ctx
114            .config
115            .get(&format!("preprocessor.{}", self.name()))
116            .ok()
117            .flatten()
118            .unwrap_or_default();
119        let mut compiler = Compiler::new();
120
121        // Set options from config
122        let opts = TypstProcessorOptions {
123            preamble: config.preamble.unwrap_or_else(|| {
124                String::from("#set page(width: auto, height: auto, margin: 0.5em)")
125            }),
126            inline_preamble: config.inline_preamble,
127            display_preamble: config.display_preamble,
128        };
129
130        let mut db = fontdb::Database::new();
131        // Load fonts from the config
132        if let Some(fonts) = config.fonts {
133            for font_path in fonts.into_vec() {
134                db.load_fonts_dir(font_path);
135            }
136        }
137        // Load system fonts, lower priority
138        db.load_system_fonts();
139
140        // Add all fonts in db to the compiler
141        for face in db.faces() {
142            let Some(info) = db.with_face_data(face.id, FontInfo::new).flatten() else {
143                eprintln!(
144                    "Warning: Failed to load font info for {:?}, skipping",
145                    face.source
146                );
147                continue;
148            };
149            compiler.book.push(info);
150            let font = match &face.source {
151                fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => {
152                    match std::fs::read(path) {
153                        Ok(bytes) => Font::new(Bytes::new(bytes), face.index),
154                        Err(e) => {
155                            eprintln!(
156                                "Warning: Failed to read font file {:?}: {}, skipping",
157                                path, e
158                            );
159                            continue;
160                        }
161                    }
162                }
163                fontdb::Source::Binary(data) => {
164                    Font::new(Bytes::new(data.as_ref().as_ref().to_vec()), face.index)
165                }
166            };
167            if let Some(font) = font {
168                compiler.fonts.push(font);
169            }
170        }
171
172        #[cfg(feature = "embed-fonts")]
173        {
174            // Load typst embedded fonts, lowest priority
175            for data in typst_assets::fonts() {
176                let buffer = Bytes::new(data);
177                for font in Font::iter(buffer) {
178                    compiler.book.push(font.info().clone());
179                    compiler.fonts.push(font);
180                }
181            }
182        }
183
184        // Set the cache dir
185        if let Some(ref cache) = config.cache {
186            compiler.cache = PathBuf::from(cache);
187        }
188
189        // record if any errors occurred
190        let mut res = None;
191
192        book.for_each_mut(|item| {
193            if let Some(Err(_)) = res {
194                return;
195            }
196
197            if let BookItem::Chapter(ref mut chapter) = *item {
198                res = Some(self.convert_typst(chapter, &compiler, &opts).map(|c| {
199                    chapter.content = c;
200                }))
201            }
202        });
203
204        res.unwrap_or(Ok(())).map(|_| book)
205    }
206
207    fn supports_renderer(&self, renderer: &str) -> Result<bool> {
208        Ok(renderer == "html")
209    }
210}
211
212impl TypstProcessor {
213    fn convert_typst(
214        &self,
215        chapter: &Chapter,
216        compiler: &Compiler,
217        opts: &TypstProcessorOptions,
218    ) -> Result<String> {
219        let chapter_name = chapter.name.as_str();
220        let mut typst_blocks = Vec::new();
221
222        let mut pulldown_cmark_opts = Options::empty();
223        pulldown_cmark_opts.insert(Options::ENABLE_TABLES);
224        pulldown_cmark_opts.insert(Options::ENABLE_FOOTNOTES);
225        pulldown_cmark_opts.insert(Options::ENABLE_STRIKETHROUGH);
226        pulldown_cmark_opts.insert(Options::ENABLE_TASKLISTS);
227        pulldown_cmark_opts.insert(Options::ENABLE_MATH);
228
229        let parser = Parser::new_ext(&chapter.content, pulldown_cmark_opts);
230        for (e, span) in parser.into_offset_iter() {
231            if let Event::InlineMath(math_content) = e {
232                typst_blocks.push((
233                    span,
234                    format!(
235                        "{}\n${math_content}$",
236                        opts.inline_preamble.as_ref().unwrap_or(&opts.preamble)
237                    ),
238                    true,
239                ))
240            } else if let Event::DisplayMath(math_content) = e {
241                let math_content = math_content.trim();
242                typst_blocks.push((
243                    span,
244                    format!(
245                        "{}\n$ {math_content} $",
246                        opts.display_preamble.as_ref().unwrap_or(&opts.preamble)
247                    ),
248                    false,
249                ))
250            }
251        }
252
253        let mut content = chapter.content.to_string();
254
255        for (span, block, inline) in typst_blocks.iter().rev() {
256            let pre_content = &content[0..span.start];
257            let post_content = &content[span.end..];
258
259            let svg = compiler.render(block.clone()).map_err(|e: CompileError| {
260                anyhow!("Failed to render math in chapter '{}': {}", chapter_name, e)
261            })?;
262
263            content = match inline {
264                true => format!(
265                    "{}<span class=\"typst-inline\">{}</span>{}",
266                    pre_content, svg, post_content
267                ),
268                false => format!(
269                    "{}<div class=\"typst-display\">{}</div>{}",
270                    pre_content, svg, post_content
271                ),
272            };
273        }
274
275        Ok(content)
276    }
277}