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//! - `color_mode`: Color mode for SVG output (`auto` or `static`)
24//! - `code_tag`: Language tag for code blocks to render as Typst (default: `typst,render`)
25//! - `enable_math`: Enable rendering of math blocks (default: `true`)
26//! - `enable_code`: Enable rendering of Typst code blocks (default: `true`)
27
28use std::path::PathBuf;
29
30use anyhow::anyhow;
31use mdbook_preprocessor::book::{Book, BookItem, Chapter};
32use mdbook_preprocessor::errors::Result;
33use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
34use serde::Deserialize;
35
36mod compiler;
37use compiler::{CompileError, Compiler};
38use typst::foundations::Bytes;
39use typst::text::{Font, FontInfo};
40
41/// Options that control how Typst renders math blocks.
42///
43/// These options allow customization of the Typst preamble used for
44/// inline and display math rendering.
45pub struct TypstProcessorOptions {
46    /// Default preamble added before each math block.
47    ///
48    /// This is used as a fallback if `inline_preamble` or `display_preamble`
49    /// is not set. The default value sets up an auto-sized page with minimal margins.
50    pub preamble: String,
51    /// Optional preamble specifically for inline math (`$...$`).
52    ///
53    /// If `None`, the default `preamble` is used instead.
54    pub inline_preamble: Option<String>,
55    /// Optional preamble specifically for display math (`$$...$$`).
56    ///
57    /// If `None`, the default `preamble` is used instead.
58    pub display_preamble: Option<String>,
59    /// Color mode for SVG output.
60    ///
61    /// When set to `Auto`, black color (`#000000`) in SVG will be replaced
62    /// with `currentColor`, allowing CSS to control the text color for
63    /// theme support (light/dark mode).
64    pub color_mode: ColorMode,
65    /// Language tag for code blocks to render as Typst.
66    pub code_tag: String,
67    /// Enable rendering of math blocks (inline and display math).
68    pub enable_math: bool,
69    /// Enable rendering of Typst code blocks.
70    pub enable_code: bool,
71}
72
73/// Color mode for SVG output.
74///
75/// This controls how the preprocessor handles colors in the generated SVG.
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum ColorMode {
79    /// Replace black (`#000000`) with `currentColor` for CSS theme support.
80    ///
81    /// This is the default mode, which allows the SVG text color to adapt
82    /// to light/dark themes via CSS.
83    #[default]
84    Auto,
85    /// Keep colors as-is from Typst output.
86    ///
87    /// Use this mode if you want to preserve exact colors specified in Typst,
88    /// or if you're using a fixed background color.
89    Static,
90}
91
92/// Represents font configuration that accepts either a single string or an array.
93///
94/// This allows users to specify fonts in `book.toml` as either:
95/// - `fonts = "path/to/fonts"`
96/// - `fonts = ["path1", "path2"]`
97#[derive(Debug, Clone, Deserialize)]
98#[serde(untagged)]
99enum FontsConfig {
100    Single(String),
101    Multiple(Vec<String>),
102}
103
104impl FontsConfig {
105    fn into_vec(self) -> Vec<String> {
106        match self {
107            FontsConfig::Single(s) => vec![s],
108            FontsConfig::Multiple(v) => v,
109        }
110    }
111}
112
113/// Configuration for the typst-math preprocessor from book.toml
114#[derive(Debug, Clone, Default, Deserialize)]
115#[serde(default)]
116struct TypstMathConfig {
117    /// The preamble to prepend to all math blocks.
118    preamble: Option<String>,
119
120    /// Optional preamble for inline math blocks.
121    inline_preamble: Option<String>,
122
123    /// Optional preamble for display math blocks.
124    display_preamble: Option<String>,
125
126    /// Custom fonts to load
127    fonts: Option<FontsConfig>,
128
129    /// Cache directory for downloaded packages
130    cache: Option<String>,
131    #[serde(default)]
132    color_mode: ColorMode,
133
134    /// Language tag for code blocks to render as Typst.
135    /// Defaults to "typst,render" if not specified.
136    code_tag: Option<String>,
137
138    /// Enable rendering of math blocks (inline and display math).
139    /// Defaults to true if not specified.
140    enable_math: Option<bool>,
141
142    /// Enable rendering of Typst code blocks.
143    /// Defaults to true if not specified.
144    enable_code: Option<bool>,
145}
146
147/// The main preprocessor that converts math blocks to Typst-rendered SVGs.
148///
149/// This preprocessor scans markdown content for inline math (`$...$`) and
150/// display math (`$$...$$`) blocks, renders them using Typst, and replaces
151/// them with SVG images wrapped in appropriate HTML elements.
152///
153/// # Example
154///
155/// ```ignore
156/// use mdbook_typst_math::TypstProcessor;
157/// use mdbook_preprocessor::Preprocessor;
158///
159/// let processor = TypstProcessor;
160/// assert_eq!(processor.name(), "typst-math");
161/// ```
162pub struct TypstProcessor;
163
164impl Preprocessor for TypstProcessor {
165    fn name(&self) -> &str {
166        "typst-math"
167    }
168
169    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
170        let config: TypstMathConfig = ctx
171            .config
172            .get(&format!("preprocessor.{}", self.name()))
173            .ok()
174            .flatten()
175            .unwrap_or_default();
176        let mut compiler = Compiler::new();
177
178        // Set options from config
179        let opts = TypstProcessorOptions {
180            preamble: config.preamble.unwrap_or_else(|| {
181                String::from("#set page(width: auto, height: auto, margin: 0.5em, fill: none)")
182            }),
183            inline_preamble: config.inline_preamble,
184            display_preamble: config.display_preamble,
185            color_mode: config.color_mode,
186            code_tag: config
187                .code_tag
188                .unwrap_or_else(|| String::from("typst,render")),
189            enable_math: config.enable_math.unwrap_or(true),
190            enable_code: config.enable_code.unwrap_or(true),
191        };
192
193        let mut db = fontdb::Database::new();
194        // Load fonts from the config
195        if let Some(fonts) = config.fonts {
196            for font_path in fonts.into_vec() {
197                db.load_fonts_dir(font_path);
198            }
199        }
200        // Load system fonts, lower priority
201        db.load_system_fonts();
202
203        // Add all fonts in db to the compiler
204        for face in db.faces() {
205            let Some(info) = db.with_face_data(face.id, FontInfo::new).flatten() else {
206                eprintln!(
207                    "Warning: Failed to load font info for {:?}, skipping",
208                    face.source
209                );
210                continue;
211            };
212            compiler.book.push(info);
213            let font = match &face.source {
214                fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => {
215                    match std::fs::read(path) {
216                        Ok(bytes) => Font::new(Bytes::new(bytes), face.index),
217                        Err(e) => {
218                            eprintln!(
219                                "Warning: Failed to read font file {:?}: {}, skipping",
220                                path, e
221                            );
222                            continue;
223                        }
224                    }
225                }
226                fontdb::Source::Binary(data) => {
227                    Font::new(Bytes::new(data.as_ref().as_ref().to_vec()), face.index)
228                }
229            };
230            if let Some(font) = font {
231                compiler.fonts.push(font);
232            }
233        }
234
235        #[cfg(feature = "embed-fonts")]
236        {
237            // Load typst embedded fonts, lowest priority
238            for data in typst_assets::fonts() {
239                let buffer = Bytes::new(data);
240                for font in Font::iter(buffer) {
241                    compiler.book.push(font.info().clone());
242                    compiler.fonts.push(font);
243                }
244            }
245        }
246
247        // Set the cache dir
248        if let Some(ref cache) = config.cache {
249            compiler.cache = PathBuf::from(cache);
250        }
251
252        // record if any errors occurred
253        let mut res = None;
254
255        book.for_each_mut(|item| {
256            if let Some(Err(_)) = res {
257                return;
258            }
259
260            if let BookItem::Chapter(ref mut chapter) = *item {
261                res = Some(self.convert_typst(chapter, &compiler, &opts).map(|c| {
262                    chapter.content = c;
263                }))
264            }
265        });
266
267        res.unwrap_or(Ok(())).map(|_| book)
268    }
269
270    fn supports_renderer(&self, renderer: &str) -> Result<bool> {
271        Ok(renderer == "html")
272    }
273}
274
275impl TypstProcessor {
276    fn convert_typst(
277        &self,
278        chapter: &Chapter,
279        compiler: &Compiler,
280        opts: &TypstProcessorOptions,
281    ) -> Result<String> {
282        use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
283
284        // Construct filename from chapter name and source path
285        let filename = if let Some(ref path) = chapter.source_path {
286            format!("{} {}", chapter.name, path.display())
287        } else {
288            chapter.name.clone()
289        };
290        let mut typst_blocks = Vec::new();
291
292        let mut pulldown_cmark_opts = Options::empty();
293        pulldown_cmark_opts.insert(Options::ENABLE_TABLES);
294        pulldown_cmark_opts.insert(Options::ENABLE_FOOTNOTES);
295        pulldown_cmark_opts.insert(Options::ENABLE_STRIKETHROUGH);
296        pulldown_cmark_opts.insert(Options::ENABLE_TASKLISTS);
297        pulldown_cmark_opts.insert(Options::ENABLE_MATH);
298
299        let mut in_typst_code_block = false;
300        let mut code_block_start: Option<std::ops::Range<usize>> = None;
301        let mut code_block_content = String::new();
302
303        let parser = Parser::new_ext(&chapter.content, pulldown_cmark_opts);
304        for (e, span) in parser.into_offset_iter() {
305            match e {
306                Event::InlineMath(math_content) if opts.enable_math => {
307                    let preamble = opts.inline_preamble.as_ref().unwrap_or(&opts.preamble);
308                    typst_blocks.push((
309                        span.clone(),
310                        format!("{}\n${math_content}$", preamble),
311                        true,
312                        preamble.lines().count(), // preamble line count
313                    ));
314                }
315                Event::DisplayMath(math_content) if opts.enable_math => {
316                    let math_content = math_content.trim();
317                    let preamble = opts.display_preamble.as_ref().unwrap_or(&opts.preamble);
318                    typst_blocks.push((
319                        span.clone(),
320                        format!("{}\n$ {math_content} $", preamble),
321                        false,
322                        preamble.lines().count(), // preamble line count
323                    ));
324                }
325                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) if opts.enable_code => {
326                    if lang.as_ref() == opts.code_tag.as_str() {
327                        in_typst_code_block = true;
328                        code_block_start = Some(span.clone());
329                        code_block_content.clear();
330                    }
331                }
332                Event::Text(text) if in_typst_code_block && opts.enable_code => {
333                    code_block_content.push_str(&text);
334                }
335                Event::End(TagEnd::CodeBlock) if in_typst_code_block && opts.enable_code => {
336                    if let Some(start_span) = code_block_start.take() {
337                        let preamble = opts.display_preamble.as_ref().unwrap_or(&opts.preamble);
338                        let full_span = start_span.start..span.end;
339
340                        typst_blocks.push((
341                            full_span,
342                            format!("{}\n{}", preamble, code_block_content.trim()),
343                            false, // Display mode
344                            preamble.lines().count(),
345                        ));
346                    }
347                    in_typst_code_block = false;
348                    code_block_content.clear();
349                }
350                _ => {}
351            }
352        }
353
354        let mut content = chapter.content.to_string();
355
356        for (span, block, inline, preamble_lines) in typst_blocks.iter().rev() {
357            let pre_content = &content[0..span.start];
358            let post_content = &content[span.end..];
359
360            // Calculate the line number in the original markdown
361            let markdown_line = chapter.content[..span.start].lines().count() + 1;
362
363            let mut svg = compiler
364                .render(
365                    block.clone(),
366                    Some(&filename),
367                    markdown_line,
368                    *preamble_lines,
369                )
370                .map_err(|e: CompileError| {
371                    anyhow!("Failed to render math in chapter '{}': {}", filename, e)
372                })?;
373
374            // Apply color mode transformation
375            if opts.color_mode == ColorMode::Auto {
376                svg = svg.replace(r##"fill="#000000""##, r#"fill="currentColor""#);
377                svg = svg.replace(r##"stroke="#000000""##, r#"stroke="currentColor""#);
378            }
379
380            content = match inline {
381                true => format!(
382                    "{}<span class=\"typst-inline\">{}</span>{}",
383                    pre_content, svg, post_content
384                ),
385                false => format!(
386                    "{}<div class=\"typst-display\">{}</div>{}",
387                    pre_content, svg, post_content
388                ),
389            };
390        }
391
392        Ok(content)
393    }
394}