atproto blogging
1//! LaTeX math rendering via pulldown-latex → MathML
2
3use markdown_weaver_escape::escape_html;
4use pulldown_latex::{
5 config::DisplayMode, config::RenderConfig, mathml::push_mathml, Parser, Storage,
6};
7
8/// Result of attempting to render LaTeX math
9pub enum MathResult {
10 /// Successfully rendered MathML
11 Success(String),
12 /// Rendering failed - contains fallback HTML with source and error message
13 Error { html: String, message: String },
14}
15
16/// Render LaTeX math to MathML
17///
18/// # Arguments
19/// * `latex` - The LaTeX source string (without delimiters like $ or $$)
20/// * `display_mode` - If true, render as display math (block); if false, inline
21pub fn render_math(latex: &str, display_mode: bool) -> MathResult {
22 let storage = Storage::new();
23 let parser = Parser::new(latex, &storage);
24 let config = RenderConfig {
25 display_mode: if display_mode {
26 DisplayMode::Block
27 } else {
28 DisplayMode::Inline
29 },
30 ..Default::default()
31 };
32
33 let mut mathml = String::new();
34
35 // Collect events, tracking any errors
36 let events: Vec<_> = parser.collect();
37 let errors: Vec<String> = events
38 .iter()
39 .filter_map(|e| e.as_ref().err().map(|err| err.to_string()))
40 .collect();
41
42 if errors.is_empty() {
43 // All events parsed successfully - push_mathml wants the Results directly
44 if let Err(e) = push_mathml(&mut mathml, events.into_iter(), config) {
45 return MathResult::Error {
46 html: format_error_html(latex, &e.to_string(), display_mode),
47 message: e.to_string(),
48 };
49 }
50 MathResult::Success(mathml)
51 } else {
52 // Had parse errors - return error HTML
53 let error_msg = errors.join("; ");
54 MathResult::Error {
55 html: format_error_html(latex, &error_msg, display_mode),
56 message: error_msg,
57 }
58 }
59}
60
61fn format_error_html(latex: &str, error: &str, display_mode: bool) -> String {
62 let mode_class = if display_mode {
63 "math-display"
64 } else {
65 "math-inline"
66 };
67 let mut escaped_latex = String::new();
68 let mut escaped_error = String::new();
69 // These won't fail writing to String
70 let _ = escape_html(&mut escaped_latex, latex);
71 let _ = escape_html(&mut escaped_error, error);
72 format!(
73 r#"<span class="math math-error {mode_class}" title="{escaped_error}"><code>{escaped_latex}</code></span>"#
74 )
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 #[test]
82 fn renders_inline_math() {
83 let result = render_math("x^2", false);
84 assert!(matches!(result, MathResult::Success(_)));
85 if let MathResult::Success(mathml) = result {
86 assert!(mathml.contains("<math"));
87 assert!(mathml.contains("</math>"));
88 }
89 }
90
91 #[test]
92 fn renders_display_math() {
93 let result = render_math(r"\frac{a}{b}", true);
94 assert!(matches!(result, MathResult::Success(_)));
95 if let MathResult::Success(mathml) = result {
96 assert!(mathml.contains("<math"));
97 assert!(mathml.contains("<mfrac"));
98 }
99 }
100
101 #[test]
102 fn renders_complex_math() {
103 let result = render_math(r"\sum_{i=0}^{n} x_i", true);
104 assert!(matches!(result, MathResult::Success(_)));
105 }
106
107 #[test]
108 fn handles_invalid_latex() {
109 // Unclosed brace
110 let result = render_math(r"\frac{a", false);
111 assert!(matches!(result, MathResult::Error { .. }));
112 if let MathResult::Error { html, message } = result {
113 assert!(html.contains("math-error"));
114 assert!(!message.is_empty());
115 }
116 }
117}