Skip to main content

scrybe_render/
math.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Math placeholder extraction and injection.
5//!
6//! Before running pulldown-cmark, math expressions are replaced with stable
7//! placeholder tokens. After rendering, the placeholders are replaced with
8//! `<span>`/`<div>` elements for the frontend KaTeX renderer.
9
10/// A math expression extracted from a Markdown source.
11#[derive(Debug, Clone)]
12pub struct MathPlaceholder {
13    pub index: usize,
14    pub source: String,
15    /// `true` for `$$...$$` (block), `false` for `$...$` (inline).
16    pub is_block: bool,
17}
18
19const PLACEHOLDER_PREFIX: &str = "MATH_PLACEHOLDER_";
20
21/// Pre-processes Markdown source, extracting math expressions.
22///
23/// Returns `(processed_source, Vec<MathPlaceholder>)`.
24/// The processed source has math replaced with `MATH_PLACEHOLDER_{n}` tokens.
25pub fn extract_math(source: &str) -> (String, Vec<MathPlaceholder>) {
26    let mut placeholders: Vec<MathPlaceholder> = Vec::new();
27    let mut output = String::with_capacity(source.len());
28    let chars: Vec<char> = source.chars().collect();
29    let len = chars.len();
30    let mut i = 0;
31
32    while i < len {
33        // Try block math: $$...$$
34        if i + 1 < len && chars[i] == '$' && chars[i + 1] == '$' {
35            if let Some(end) = find_closing(&chars, i + 2, "$$") {
36                let math_src: String = chars[i + 2..end].iter().collect();
37                let idx = placeholders.len();
38                placeholders.push(MathPlaceholder {
39                    index: idx,
40                    source: math_src,
41                    is_block: true,
42                });
43                output.push_str(&format!("{PLACEHOLDER_PREFIX}{idx}"));
44                i = end + 2; // skip closing $$
45                continue;
46            }
47        }
48        // Try inline math: $...$
49        if chars[i] == '$' {
50            if let Some(end) = find_closing(&chars, i + 1, "$") {
51                let math_src: String = chars[i + 1..end].iter().collect();
52                // Only treat as math if non-empty and no newline inside
53                if !math_src.is_empty() && !math_src.contains('\n') {
54                    let idx = placeholders.len();
55                    placeholders.push(MathPlaceholder {
56                        index: idx,
57                        source: math_src,
58                        is_block: false,
59                    });
60                    output.push_str(&format!("{PLACEHOLDER_PREFIX}{idx}"));
61                    i = end + 1; // skip closing $
62                    continue;
63                }
64            }
65        }
66        output.push(chars[i]);
67        i += 1;
68    }
69
70    (output, placeholders)
71}
72
73/// Finds the position (in `chars`) of the first occurrence of `closing`
74/// starting at `start`.  Returns the index of the first char of `closing`.
75fn find_closing(chars: &[char], start: usize, closing: &str) -> Option<usize> {
76    let closing_chars: Vec<char> = closing.chars().collect();
77    let clen = closing_chars.len();
78    let len = chars.len();
79    let mut i = start;
80    while i + clen <= len {
81        if chars[i..i + clen] == closing_chars[..] {
82            return Some(i);
83        }
84        i += 1;
85    }
86    None
87}
88
89/// Re-injects math placeholders as `<span>`/`<div>` elements.
90pub fn inject_math(html: &str, placeholders: &[MathPlaceholder]) -> String {
91    let mut output = html.to_owned();
92    for ph in placeholders {
93        let token = format!("{PLACEHOLDER_PREFIX}{}", ph.index);
94        let escaped = html_escape(&ph.source);
95        let replacement = if ph.is_block {
96            format!(
97                r#"<div class="math-block" data-math="{escaped}">{source}</div>"#,
98                source = ph.source
99            )
100        } else {
101            format!(
102                r#"<span class="math-inline" data-math="{escaped}">{source}</span>"#,
103                source = ph.source
104            )
105        };
106        output = output.replace(&token, &replacement);
107    }
108    output
109}
110
111/// Minimal HTML attribute escaping for `data-math` values.
112fn html_escape(s: &str) -> String {
113    s.replace('&', "&amp;")
114        .replace('"', "&quot;")
115        .replace('<', "&lt;")
116        .replace('>', "&gt;")
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_math_inline_extracted() {
125        let (processed, phs) = extract_math("Here is $x^2$ inline.");
126        assert_eq!(phs.len(), 1);
127        assert!(!phs[0].is_block);
128        assert_eq!(phs[0].source, "x^2");
129        assert!(processed.contains("MATH_PLACEHOLDER_0"));
130
131        let html = inject_math(&processed, &phs);
132        assert!(html.contains(r#"class="math-inline""#));
133        assert!(html.contains("x^2"));
134    }
135
136    #[test]
137    fn test_math_block_extracted() {
138        let (processed, phs) = extract_math("$$\\int f$$");
139        assert_eq!(phs.len(), 1);
140        assert!(phs[0].is_block);
141        assert_eq!(phs[0].source, "\\int f");
142        assert!(processed.contains("MATH_PLACEHOLDER_0"));
143
144        let html = inject_math(&processed, &phs);
145        assert!(html.contains(r#"class="math-block""#));
146        assert!(html.contains("\\int f"));
147    }
148
149    #[test]
150    fn test_no_math_passthrough() {
151        let (processed, phs) = extract_math("No math here.");
152        assert!(phs.is_empty());
153        assert_eq!(processed, "No math here.");
154    }
155
156    #[test]
157    fn test_math_escape_in_attr() {
158        let (processed, phs) = extract_math(r#"$a < b$"#);
159        assert_eq!(phs.len(), 1);
160        let html = inject_math(&processed, &phs);
161        assert!(html.contains("&lt;"));
162    }
163}