Skip to main content

logicaffeine_web/ui/components/
katex.rs

1//! KaTeX LaTeX rendering component.
2//!
3//! Renders mathematical expressions using the KaTeX library via JavaScript interop.
4//! Supports both inline and display mode rendering.
5//!
6//! # Components
7//!
8//! - [`KatexSpan`] - Renders a single LaTeX expression
9//!
10//! # Functions
11//!
12//! - [`parse_latex_in_text`] - Parses text containing `$...$` and `$$...$$` markers
13//!
14//! # Usage
15//!
16//! ```no_run
17//! # use dioxus::prelude::*;
18//! # use logicaffeine_web::ui::components::katex::KatexSpan;
19//! # fn Example() -> Element {
20//! rsx! {
21//!     // Inline math
22//!     KatexSpan { latex: "x + y".to_string() }
23//!     // Display mode (centered, larger)
24//!     KatexSpan { latex: r"\forall x".to_string(), display: true }
25//! }
26//! # }
27//! ```
28
29use dioxus::prelude::*;
30use wasm_bindgen::prelude::*;
31
32#[wasm_bindgen]
33extern "C" {
34    #[wasm_bindgen(js_namespace = window, js_name = renderKaTeX)]
35    fn render_katex(element_id: &str, latex: &str, display_mode: bool);
36}
37
38static KATEX_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
39
40fn next_katex_id() -> String {
41    let id = KATEX_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
42    format!("katex-{}", id)
43}
44
45#[component]
46pub fn KatexSpan(latex: String, #[props(default = false)] display: bool) -> Element {
47    let element_id = use_signal(|| next_katex_id());
48
49    use_effect(move || {
50        let id = element_id.read().clone();
51        let latex = latex.clone();
52        render_katex(&id, &latex, display);
53    });
54
55    rsx! {
56        span {
57            id: "{element_id}",
58            class: if display { "katex-display" } else { "katex-inline" }
59        }
60    }
61}
62
63#[derive(Debug, Clone, PartialEq)]
64pub enum TextPart {
65    Plain(String),
66    Latex { content: String, display: bool },
67}
68
69pub fn parse_latex_in_text(text: &str) -> Vec<TextPart> {
70    let mut parts = Vec::new();
71    let mut remaining = text;
72
73    while !remaining.is_empty() {
74        let display_pos = remaining.find("$$");
75        let inline_pos = remaining.find('$');
76
77        match (display_pos, inline_pos) {
78            (Some(d), Some(i)) if d <= i => {
79                if d > 0 {
80                    parts.push(TextPart::Plain(remaining[..d].to_string()));
81                }
82                remaining = &remaining[d + 2..];
83                if let Some(end) = remaining.find("$$") {
84                    parts.push(TextPart::Latex {
85                        content: remaining[..end].to_string(),
86                        display: true,
87                    });
88                    remaining = &remaining[end + 2..];
89                } else {
90                    parts.push(TextPart::Plain(format!("$${}", remaining)));
91                    break;
92                }
93            }
94            (_, Some(i)) => {
95                if i > 0 {
96                    parts.push(TextPart::Plain(remaining[..i].to_string()));
97                }
98                remaining = &remaining[i + 1..];
99                if let Some(end) = remaining.find('$') {
100                    parts.push(TextPart::Latex {
101                        content: remaining[..end].to_string(),
102                        display: false,
103                    });
104                    remaining = &remaining[end + 1..];
105                } else {
106                    parts.push(TextPart::Plain(format!("${}", remaining)));
107                    break;
108                }
109            }
110            (Some(d), None) => {
111                if d > 0 {
112                    parts.push(TextPart::Plain(remaining[..d].to_string()));
113                }
114                remaining = &remaining[d + 2..];
115                if let Some(end) = remaining.find("$$") {
116                    parts.push(TextPart::Latex {
117                        content: remaining[..end].to_string(),
118                        display: true,
119                    });
120                    remaining = &remaining[end + 2..];
121                } else {
122                    parts.push(TextPart::Plain(format!("$${}", remaining)));
123                    break;
124                }
125            }
126            (None, None) => {
127                parts.push(TextPart::Plain(remaining.to_string()));
128                break;
129            }
130        }
131    }
132
133    parts
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_parse_plain_text() {
142        let parts = parse_latex_in_text("Hello world");
143        assert_eq!(parts.len(), 1);
144        assert_eq!(parts[0], TextPart::Plain("Hello world".to_string()));
145    }
146
147    #[test]
148    fn test_parse_inline_latex() {
149        let parts = parse_latex_in_text("The formula $x + y$ is simple");
150        assert_eq!(parts.len(), 3);
151        assert_eq!(parts[0], TextPart::Plain("The formula ".to_string()));
152        assert_eq!(parts[1], TextPart::Latex { content: "x + y".to_string(), display: false });
153        assert_eq!(parts[2], TextPart::Plain(" is simple".to_string()));
154    }
155
156    #[test]
157    fn test_parse_display_latex() {
158        let parts = parse_latex_in_text("Consider: $$\\forall x$$ as shown");
159        assert_eq!(parts.len(), 3);
160        assert_eq!(parts[0], TextPart::Plain("Consider: ".to_string()));
161        assert_eq!(parts[1], TextPart::Latex { content: "\\forall x".to_string(), display: true });
162        assert_eq!(parts[2], TextPart::Plain(" as shown".to_string()));
163    }
164
165    #[test]
166    fn test_parse_mixed() {
167        let parts = parse_latex_in_text("$A$ and $B$ implies $$A \\land B$$");
168        assert_eq!(parts.len(), 5);
169        assert_eq!(parts[0], TextPart::Latex { content: "A".to_string(), display: false });
170        assert_eq!(parts[1], TextPart::Plain(" and ".to_string()));
171        assert_eq!(parts[2], TextPart::Latex { content: "B".to_string(), display: false });
172        assert_eq!(parts[3], TextPart::Plain(" implies ".to_string()));
173        assert_eq!(parts[4], TextPart::Latex { content: "A \\land B".to_string(), display: true });
174    }
175
176    #[test]
177    fn test_unclosed_inline() {
178        let parts = parse_latex_in_text("Start $unclosed");
179        assert_eq!(parts.len(), 2);
180        assert_eq!(parts[0], TextPart::Plain("Start ".to_string()));
181        assert_eq!(parts[1], TextPart::Plain("$unclosed".to_string()));
182    }
183}