1use 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
41pub struct TypstProcessorOptions {
46 pub preamble: String,
51 pub inline_preamble: Option<String>,
55 pub display_preamble: Option<String>,
59 pub color_mode: ColorMode,
65 pub code_tag: String,
67 pub enable_math: bool,
69 pub enable_code: bool,
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum ColorMode {
79 #[default]
84 Auto,
85 Static,
90}
91
92#[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#[derive(Debug, Clone, Default, Deserialize)]
115#[serde(default)]
116struct TypstMathConfig {
117 preamble: Option<String>,
119
120 inline_preamble: Option<String>,
122
123 display_preamble: Option<String>,
125
126 fonts: Option<FontsConfig>,
128
129 cache: Option<String>,
131 #[serde(default)]
132 color_mode: ColorMode,
133
134 code_tag: Option<String>,
137
138 enable_math: Option<bool>,
141
142 enable_code: Option<bool>,
145}
146
147pub 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 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 if let Some(fonts) = config.fonts {
196 for font_path in fonts.into_vec() {
197 db.load_fonts_dir(font_path);
198 }
199 }
200 db.load_system_fonts();
202
203 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 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 if let Some(ref cache) = config.cache {
249 compiler.cache = PathBuf::from(cache);
250 }
251
252 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 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(), ));
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(), ));
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, 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 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 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}