use std::path::PathBuf;
use anyhow::anyhow;
use mdbook_preprocessor::book::{Book, BookItem, Chapter};
use mdbook_preprocessor::errors::Result;
use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
use serde::Deserialize;
mod compiler;
use compiler::{CompileError, Compiler};
use typst::foundations::Bytes;
use typst::text::{Font, FontInfo};
pub struct TypstProcessorOptions {
pub preamble: String,
pub inline_preamble: Option<String>,
pub display_preamble: Option<String>,
pub color_mode: ColorMode,
pub code_tag: String,
pub enable_math: bool,
pub enable_code: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ColorMode {
#[default]
Auto,
Static,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum FontsConfig {
Single(String),
Multiple(Vec<String>),
}
impl FontsConfig {
fn into_vec(self) -> Vec<String> {
match self {
FontsConfig::Single(s) => vec![s],
FontsConfig::Multiple(v) => v,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct TypstMathConfig {
preamble: Option<String>,
inline_preamble: Option<String>,
display_preamble: Option<String>,
fonts: Option<FontsConfig>,
cache: Option<String>,
#[serde(default)]
color_mode: ColorMode,
code_tag: Option<String>,
enable_math: Option<bool>,
enable_code: Option<bool>,
}
pub struct TypstProcessor;
impl Preprocessor for TypstProcessor {
fn name(&self) -> &str {
"typst-math"
}
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
let config: TypstMathConfig = ctx
.config
.get(&format!("preprocessor.{}", self.name()))
.ok()
.flatten()
.unwrap_or_default();
let mut compiler = Compiler::new();
let opts = TypstProcessorOptions {
preamble: config.preamble.unwrap_or_else(|| {
String::from("#set page(width: auto, height: auto, margin: 0.5em, fill: none)")
}),
inline_preamble: config.inline_preamble,
display_preamble: config.display_preamble,
color_mode: config.color_mode,
code_tag: config
.code_tag
.unwrap_or_else(|| String::from("typst,render")),
enable_math: config.enable_math.unwrap_or(true),
enable_code: config.enable_code.unwrap_or(true),
};
let mut db = fontdb::Database::new();
if let Some(fonts) = config.fonts {
for font_path in fonts.into_vec() {
db.load_fonts_dir(font_path);
}
}
db.load_system_fonts();
for face in db.faces() {
let Some(info) = db.with_face_data(face.id, FontInfo::new).flatten() else {
eprintln!(
"Warning: Failed to load font info for {:?}, skipping",
face.source
);
continue;
};
compiler.book.push(info);
let font = match &face.source {
fontdb::Source::File(path) | fontdb::Source::SharedFile(path, _) => {
match std::fs::read(path) {
Ok(bytes) => Font::new(Bytes::new(bytes), face.index),
Err(e) => {
eprintln!(
"Warning: Failed to read font file {:?}: {}, skipping",
path, e
);
continue;
}
}
}
fontdb::Source::Binary(data) => {
Font::new(Bytes::new(data.as_ref().as_ref().to_vec()), face.index)
}
};
if let Some(font) = font {
compiler.fonts.push(font);
}
}
#[cfg(feature = "embed-fonts")]
{
for data in typst_assets::fonts() {
let buffer = Bytes::new(data);
for font in Font::iter(buffer) {
compiler.book.push(font.info().clone());
compiler.fonts.push(font);
}
}
}
if let Some(ref cache) = config.cache {
compiler.cache = PathBuf::from(cache);
}
let mut res = None;
book.for_each_mut(|item| {
if let Some(Err(_)) = res {
return;
}
if let BookItem::Chapter(ref mut chapter) = *item {
res = Some(self.convert_typst(chapter, &compiler, &opts).map(|c| {
chapter.content = c;
}))
}
});
res.unwrap_or(Ok(())).map(|_| book)
}
fn supports_renderer(&self, renderer: &str) -> Result<bool> {
Ok(renderer == "html")
}
}
impl TypstProcessor {
fn convert_typst(
&self,
chapter: &Chapter,
compiler: &Compiler,
opts: &TypstProcessorOptions,
) -> Result<String> {
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
let filename = if let Some(ref path) = chapter.source_path {
format!("{} {}", chapter.name, path.display())
} else {
chapter.name.clone()
};
let mut typst_blocks = Vec::new();
let mut pulldown_cmark_opts = Options::empty();
pulldown_cmark_opts.insert(Options::ENABLE_TABLES);
pulldown_cmark_opts.insert(Options::ENABLE_FOOTNOTES);
pulldown_cmark_opts.insert(Options::ENABLE_STRIKETHROUGH);
pulldown_cmark_opts.insert(Options::ENABLE_TASKLISTS);
pulldown_cmark_opts.insert(Options::ENABLE_MATH);
let mut in_typst_code_block = false;
let mut code_block_start: Option<std::ops::Range<usize>> = None;
let mut code_block_content = String::new();
let parser = Parser::new_ext(&chapter.content, pulldown_cmark_opts);
for (e, span) in parser.into_offset_iter() {
match e {
Event::InlineMath(math_content) if opts.enable_math => {
let preamble = opts.inline_preamble.as_ref().unwrap_or(&opts.preamble);
typst_blocks.push((
span.clone(),
format!("{}\n${math_content}$", preamble),
true,
preamble.lines().count(), ));
}
Event::DisplayMath(math_content) if opts.enable_math => {
let math_content = math_content.trim();
let preamble = opts.display_preamble.as_ref().unwrap_or(&opts.preamble);
typst_blocks.push((
span.clone(),
format!("{}\n$ {math_content} $", preamble),
false,
preamble.lines().count(), ));
}
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) if opts.enable_code => {
if lang.as_ref() == opts.code_tag.as_str() {
in_typst_code_block = true;
code_block_start = Some(span.clone());
code_block_content.clear();
}
}
Event::Text(text) if in_typst_code_block && opts.enable_code => {
code_block_content.push_str(&text);
}
Event::End(TagEnd::CodeBlock) if in_typst_code_block && opts.enable_code => {
if let Some(start_span) = code_block_start.take() {
let preamble = opts.display_preamble.as_ref().unwrap_or(&opts.preamble);
let full_span = start_span.start..span.end;
typst_blocks.push((
full_span,
format!("{}\n{}", preamble, code_block_content.trim()),
false, preamble.lines().count(),
));
}
in_typst_code_block = false;
code_block_content.clear();
}
_ => {}
}
}
let mut content = chapter.content.to_string();
for (span, block, inline, preamble_lines) in typst_blocks.iter().rev() {
let pre_content = &content[0..span.start];
let post_content = &content[span.end..];
let markdown_line = chapter.content[..span.start].lines().count() + 1;
let mut svg = compiler
.render(
block.clone(),
Some(&filename),
markdown_line,
*preamble_lines,
)
.map_err(|e: CompileError| {
anyhow!("Failed to render math in chapter '{}': {}", filename, e)
})?;
if opts.color_mode == ColorMode::Auto {
svg = svg.replace(r##"fill="#000000""##, r#"fill="currentColor""#);
svg = svg.replace(r##"stroke="#000000""##, r#"stroke="currentColor""#);
}
content = match inline {
true => format!(
"{}<span class=\"typst-inline\">{}</span>{}",
pre_content, svg, post_content
),
false => format!(
"{}<div class=\"typst-display\">{}</div>{}",
pre_content, svg, post_content
),
};
}
Ok(content)
}
}