Skip to main content

merman_render/
math.rs

1//! Optional math rendering hooks.
2//!
3//! Upstream Mermaid renders `$$...$$` fragments via KaTeX and measures the resulting HTML in a
4//! browser DOM. merman is headless and pure-Rust by default, so math rendering is modeled as an
5//! optional, pluggable backend.
6//!
7//! The default implementation is a no-op. For parity work, a Node.js-backed KaTeX renderer is
8//! provided, and the `ratex-math` feature enables a pure-Rust RaTeX renderer for supported labels.
9
10#[cfg(feature = "ratex-math")]
11use crate::text::split_html_br_lines;
12use crate::text::{TextMetrics, TextStyle, WrapMode};
13use merman_core::MermaidConfig;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::fmt::Write as _;
17use std::io::Write as _;
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::sync::Mutex;
21
22/// Optional math renderer used to transform label HTML and (optionally) provide measurements.
23///
24/// Implementations should be:
25/// - deterministic (stable output across runs),
26/// - side-effect free (no global mutations),
27/// - non-panicking (return `None` to decline handling).
28pub trait MathRenderer: std::fmt::Debug {
29    /// Attempts to render math fragments within an HTML label string.
30    ///
31    /// If the renderer declines to handle the input, it should return `None`.
32    ///
33    /// The returned string is treated as raw HTML and will still be sanitized by merman before
34    /// emitting into an SVG `<foreignObject>`.
35    fn render_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String>;
36
37    /// Attempts to render a Sequence `drawKatex(...)` label.
38    ///
39    /// Sequence uses a bare `foreignObject` with `width: fit-content` rather than Flowchart's
40    /// HTML-label shell, so math backends may support a slightly different surface here.
41    fn render_sequence_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String> {
42        self.render_html_label(text, config)
43    }
44
45    /// Optionally measures the rendered HTML label in pixels.
46    ///
47    /// This is intended to mirror upstream Mermaid's DOM measurement behavior for math labels.
48    /// The default implementation returns `None`.
49    fn measure_html_label(
50        &self,
51        _text: &str,
52        _config: &MermaidConfig,
53        _style: &TextStyle,
54        _max_width_px: Option<f64>,
55        _wrap_mode: WrapMode,
56    ) -> Option<TextMetrics> {
57        None
58    }
59
60    /// Optionally measures a Sequence `drawKatex(...)` label in pixels.
61    ///
62    /// Mermaid Sequence does not wrap KaTeX labels in the flowchart HTML-label shell; it appends
63    /// a bare `<foreignObject><div style="width: fit-content;">...</div></foreignObject>`.
64    /// This hook lets Sequence callers avoid inheriting flowchart-specific table-cell metrics.
65    fn measure_sequence_html_label(
66        &self,
67        _text: &str,
68        _config: &MermaidConfig,
69    ) -> Option<TextMetrics> {
70        None
71    }
72}
73
74/// Default math renderer: does nothing.
75#[derive(Debug, Default, Clone, Copy)]
76pub struct NoopMathRenderer;
77
78impl MathRenderer for NoopMathRenderer {
79    fn render_html_label(&self, _text: &str, _config: &MermaidConfig) -> Option<String> {
80        None
81    }
82}
83
84/// Pure-Rust math renderer backed by RaTeX.
85///
86/// The first Flowchart surface is intentionally narrow: labels where each non-empty line is a
87/// single `$$...$$` formula. Sequence additionally supports one formula embedded in surrounding
88/// prose per line, matching Mermaid's `drawKatex(...)` shell.
89#[cfg(feature = "ratex-math")]
90#[derive(Debug, Default, Clone, Copy)]
91pub struct RatexMathRenderer;
92
93#[cfg(feature = "ratex-math")]
94#[derive(Debug, Clone)]
95struct RatexRenderedMath {
96    width_em: f64,
97    height_em: f64,
98    line_count: usize,
99}
100
101#[cfg(feature = "ratex-math")]
102impl RatexMathRenderer {
103    fn normalized_text(text: &str) -> String {
104        text.replace("\\\\", "\\")
105    }
106
107    fn math_only_lines(text: &str) -> Option<Vec<String>> {
108        let normalized = Self::normalized_text(text);
109        let mut formulas = Vec::new();
110        for raw_line in split_html_br_lines(&normalized) {
111            let line = raw_line.trim();
112            if line.is_empty() {
113                continue;
114            }
115            let inner = line.strip_prefix("$$")?.strip_suffix("$$")?;
116            if inner.contains("$$") {
117                return None;
118            }
119            formulas.push(inner.to_string());
120        }
121        if formulas.is_empty() {
122            None
123        } else {
124            Some(formulas)
125        }
126    }
127
128    fn render_formula_svg_em(latex: &str) -> Option<(String, f64, f64)> {
129        let ast = ratex_parser::parse(latex).ok()?;
130        let layout_options = ratex_layout::LayoutOptions::default()
131            .with_style(ratex_types::MathStyle::Display)
132            .with_color(ratex_types::Color::BLACK);
133        let layout_box = ratex_layout::layout(&ast, &layout_options);
134        let display_list = ratex_layout::to_display_list(&layout_box);
135        let width_em = display_list.width.max(0.0);
136        let height_em = display_list.total_height().max(0.0);
137        let svg = ratex_svg::render_to_svg(
138            &display_list,
139            &ratex_svg::SvgOptions {
140                font_size: 1.0,
141                padding: 0.0,
142                stroke_width: 0.04,
143                embed_glyphs: true,
144                font_dir: String::new(),
145            },
146        );
147        Some((
148            Self::svg_with_em_size(svg, width_em, height_em),
149            width_em,
150            height_em,
151        ))
152    }
153
154    fn svg_with_em_size(svg: String, width_em: f64, height_em: f64) -> String {
155        let Some(open_end) = svg.find('>') else {
156            return svg;
157        };
158        let Some(body_with_close) = svg.get(open_end + 1..) else {
159            return svg;
160        };
161        let Some(body) = body_with_close.strip_suffix("</svg>") else {
162            return svg;
163        };
164        let width = Self::fmt_num(width_em);
165        let height = Self::fmt_num(height_em);
166        format!(
167            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}em" height="{height}em">{body}</svg>"#
168        )
169    }
170
171    fn render_math_only_label(text: &str) -> Option<RatexRenderedMath> {
172        let formulas = Self::math_only_lines(text)?;
173        let mut width_em: f64 = 0.0;
174        let mut height_em: f64 = 0.0;
175        let mut line_count = 0usize;
176        for formula in formulas {
177            let (_svg, line_width_em, line_height_em) = Self::render_formula_svg_em(&formula)?;
178            width_em = width_em.max(line_width_em);
179            height_em += line_height_em;
180            line_count += 1;
181        }
182        Some(RatexRenderedMath {
183            width_em,
184            height_em,
185            line_count: line_count.max(1),
186        })
187    }
188
189    fn render_katex_like_line_html(line: &str) -> Option<String> {
190        if !line.contains("$$") {
191            return Some(line.to_string());
192        }
193        let start = line.find("$$")?;
194        let content_start = start + 2;
195        let end_start = line[content_start..].rfind("$$")? + content_start;
196        if end_start < content_start {
197            return None;
198        }
199        let formula = &line[content_start..end_start];
200        if formula.contains("$$") {
201            return None;
202        }
203        let (svg, _width_em, _height_em) = Self::render_formula_svg_em(formula)?;
204        let mut html = String::with_capacity(line.len() + svg.len());
205        html.push_str(&line[..start]);
206        html.push_str(&svg);
207        html.push_str(&line[end_start + 2..]);
208        Some(html)
209    }
210
211    fn render_katex_like_label(text: &str) -> Option<String> {
212        let normalized = Self::normalized_text(text);
213        if !normalized.contains("$$") {
214            return None;
215        }
216
217        let mut html = String::new();
218        let mut saw_math = false;
219        for line in split_html_br_lines(&normalized) {
220            if line.contains("$$") {
221                saw_math = true;
222                let rendered_line = Self::render_katex_like_line_html(line)?;
223                let _ = write!(
224                    &mut html,
225                    r#"<div style="display: flex; align-items: center; justify-content: center; white-space: nowrap;">{rendered_line}</div>"#
226                );
227            } else {
228                let _ = write!(&mut html, "<div>{line}</div>");
229            }
230        }
231
232        saw_math.then_some(html)
233    }
234
235    fn metrics_from_em(rendered: &RatexRenderedMath, font_size: f64) -> TextMetrics {
236        let font_size = font_size.max(1.0);
237        TextMetrics {
238            width: crate::text::round_to_1_64_px(rendered.width_em * font_size),
239            height: crate::text::round_to_1_64_px(rendered.height_em * font_size),
240            line_count: rendered.line_count,
241        }
242    }
243
244    fn fmt_num(n: f64) -> String {
245        let s = format!("{n:.6}");
246        let s = s.trim_end_matches('0').trim_end_matches('.');
247        if s.is_empty() || s == "-" {
248            "0".to_string()
249        } else {
250            s.to_string()
251        }
252    }
253}
254
255#[cfg(feature = "ratex-math")]
256impl MathRenderer for RatexMathRenderer {
257    fn render_html_label(&self, text: &str, _config: &MermaidConfig) -> Option<String> {
258        if !text.contains("$$") {
259            return None;
260        }
261        Self::render_katex_like_label(text)
262    }
263
264    fn render_sequence_html_label(&self, text: &str, _config: &MermaidConfig) -> Option<String> {
265        Self::render_katex_like_label(text)
266    }
267
268    fn measure_html_label(
269        &self,
270        text: &str,
271        _config: &MermaidConfig,
272        style: &TextStyle,
273        _max_width_px: Option<f64>,
274        wrap_mode: WrapMode,
275    ) -> Option<TextMetrics> {
276        if wrap_mode != WrapMode::HtmlLike || !text.contains("$$") {
277            return None;
278        }
279        let rendered = Self::render_math_only_label(text)?;
280        Some(Self::metrics_from_em(&rendered, style.font_size))
281    }
282
283    fn measure_sequence_html_label(
284        &self,
285        text: &str,
286        _config: &MermaidConfig,
287    ) -> Option<TextMetrics> {
288        if !text.contains("$$") {
289            return None;
290        }
291        let rendered = Self::render_math_only_label(text)?;
292        Some(Self::metrics_from_em(
293            &rendered,
294            TextStyle::default().font_size,
295        ))
296    }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Hash)]
300struct RenderCacheKey {
301    text: String,
302    legacy_mathml: bool,
303    force_legacy_mathml: bool,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Hash)]
307struct ProbeCacheKey {
308    render: RenderCacheKey,
309    font_family: Option<String>,
310    font_size_bits: u64,
311    font_weight: Option<String>,
312    max_width_bits: u64,
313}
314
315#[derive(Debug, Clone)]
316struct ProbeCacheValue {
317    html: String,
318    width: f64,
319    height: f64,
320    line_count: usize,
321}
322
323#[derive(Debug, Serialize)]
324struct NodeRenderRequest {
325    text: String,
326    config: NodeMathConfig,
327}
328
329#[derive(Debug, Serialize)]
330struct NodeProbeRequest {
331    text: String,
332    config: NodeMathConfig,
333    #[serde(rename = "styleCss")]
334    style_css: String,
335    #[serde(rename = "maxWidthPx")]
336    max_width_px: f64,
337}
338
339#[derive(Debug, Serialize)]
340struct NodeMathConfig {
341    #[serde(rename = "legacyMathML")]
342    legacy_mathml: bool,
343    #[serde(rename = "forceLegacyMathML")]
344    force_legacy_mathml: bool,
345}
346
347#[derive(Debug, Deserialize)]
348struct NodeRenderResponse {
349    html: String,
350}
351
352#[derive(Debug, Deserialize)]
353struct NodeProbeResponse {
354    html: String,
355    width: f64,
356    height: f64,
357}
358
359/// Optional KaTeX backend that shells out to a local Node.js toolchain.
360///
361/// This backend is intended for parity work where a real browser DOM is available. It mirrors
362/// Mermaid's flowchart HTML-label KaTeX path closely by:
363/// - rendering KaTeX through the local `katex` npm package, and
364/// - measuring the wrapped `<foreignObject>` HTML through local `puppeteer`.
365///
366/// The backend is completely opt-in; if the configured Node.js environment is unavailable or the
367/// probe fails, it simply returns `None` and lets callers fall back to the default text path.
368#[derive(Debug)]
369pub struct NodeKatexMathRenderer {
370    node_cwd: PathBuf,
371    node_command: PathBuf,
372    render_cache: Mutex<HashMap<RenderCacheKey, Option<String>>>,
373    probe_cache: Mutex<HashMap<ProbeCacheKey, Option<ProbeCacheValue>>>,
374    sequence_probe_cache: Mutex<HashMap<RenderCacheKey, Option<ProbeCacheValue>>>,
375}
376
377impl NodeKatexMathRenderer {
378    pub fn new(node_cwd: impl Into<PathBuf>) -> Self {
379        Self {
380            node_cwd: node_cwd.into(),
381            node_command: PathBuf::from("node"),
382            render_cache: Mutex::new(HashMap::new()),
383            probe_cache: Mutex::new(HashMap::new()),
384            sequence_probe_cache: Mutex::new(HashMap::new()),
385        }
386    }
387
388    pub fn with_node_command(mut self, node_command: impl Into<PathBuf>) -> Self {
389        self.node_command = node_command.into();
390        self
391    }
392
393    fn script_path() -> PathBuf {
394        Path::new(env!("CARGO_MANIFEST_DIR"))
395            .join("assets")
396            .join("katex_flowchart_probe.cjs")
397    }
398
399    fn normalized_text(text: &str) -> String {
400        text.replace("\\\\", "\\")
401    }
402
403    fn math_config(config: &MermaidConfig) -> NodeMathConfig {
404        let config_value = config.as_value();
405        let legacy_mathml = config_value
406            .get("legacyMathML")
407            .and_then(serde_json::Value::as_bool)
408            .unwrap_or(false);
409        let force_legacy_mathml = config_value
410            .get("forceLegacyMathML")
411            .and_then(serde_json::Value::as_bool)
412            .unwrap_or(false);
413        NodeMathConfig {
414            legacy_mathml,
415            force_legacy_mathml,
416        }
417    }
418
419    fn render_key(text: &str, config: &MermaidConfig) -> RenderCacheKey {
420        let config = Self::math_config(config);
421        RenderCacheKey {
422            text: Self::normalized_text(text),
423            legacy_mathml: config.legacy_mathml,
424            force_legacy_mathml: config.force_legacy_mathml,
425        }
426    }
427
428    fn style_css(style: &TextStyle) -> String {
429        let mut out = String::new();
430        let font_family = style
431            .font_family
432            .as_deref()
433            .unwrap_or("\"trebuchet ms\",verdana,arial,sans-serif");
434        let _ = write!(&mut out, "font-size: {}px;", style.font_size);
435        let _ = write!(&mut out, "font-family: {};", font_family);
436        if let Some(font_weight) = style.font_weight.as_deref() {
437            if !font_weight.trim().is_empty() {
438                let _ = write!(&mut out, "font-weight: {};", font_weight.trim());
439            }
440        }
441        out
442    }
443
444    fn run_node_request<T, R>(&self, mode: &str, payload: &T) -> Option<R>
445    where
446        T: Serialize,
447        R: for<'de> Deserialize<'de>,
448    {
449        if !self.node_cwd.join("package.json").is_file() {
450            return None;
451        }
452
453        let mut child = Command::new(&self.node_command)
454            .arg(Self::script_path())
455            .arg(mode)
456            .current_dir(&self.node_cwd)
457            .stdin(Stdio::piped())
458            .stdout(Stdio::piped())
459            .stderr(Stdio::piped())
460            .spawn()
461            .ok()?;
462
463        if let Some(mut stdin) = child.stdin.take() {
464            if serde_json::to_writer(&mut stdin, payload).is_err() {
465                return None;
466            }
467            let _ = stdin.flush();
468        }
469
470        let output = child.wait_with_output().ok()?;
471        if !output.status.success() {
472            return None;
473        }
474
475        serde_json::from_slice(&output.stdout).ok()
476    }
477
478    fn render_cached(&self, text: &str, config: &MermaidConfig) -> Option<String> {
479        let key = Self::render_key(text, config);
480        if let Some(cached) = self
481            .render_cache
482            .lock()
483            .ok()
484            .and_then(|cache| cache.get(&key).cloned())
485        {
486            return cached;
487        }
488
489        let response: Option<NodeRenderResponse> = self.run_node_request(
490            "render",
491            &NodeRenderRequest {
492                text: key.text.clone(),
493                config: NodeMathConfig {
494                    legacy_mathml: key.legacy_mathml,
495                    force_legacy_mathml: key.force_legacy_mathml,
496                },
497            },
498        );
499        let html = response.map(|value| value.html);
500
501        if let Ok(mut cache) = self.render_cache.lock() {
502            cache.insert(key, html.clone());
503        }
504
505        html
506    }
507
508    fn probe_cached(
509        &self,
510        text: &str,
511        config: &MermaidConfig,
512        style: &TextStyle,
513        max_width_px: Option<f64>,
514        _wrap_mode: WrapMode,
515    ) -> Option<ProbeCacheValue> {
516        let render = Self::render_key(text, config);
517        let max_width = max_width_px.unwrap_or(200.0).max(1.0);
518        let key = ProbeCacheKey {
519            render: render.clone(),
520            font_family: style.font_family.clone(),
521            font_size_bits: style.font_size.to_bits(),
522            font_weight: style.font_weight.clone(),
523            max_width_bits: max_width.to_bits(),
524        };
525        if let Some(cached) = self
526            .probe_cache
527            .lock()
528            .ok()
529            .and_then(|cache| cache.get(&key).cloned())
530        {
531            return cached;
532        }
533
534        let style_css = Self::style_css(style);
535        let response: Option<NodeProbeResponse> = self.run_node_request(
536            "probe",
537            &NodeProbeRequest {
538                text: render.text.clone(),
539                config: NodeMathConfig {
540                    legacy_mathml: render.legacy_mathml,
541                    force_legacy_mathml: render.force_legacy_mathml,
542                },
543                style_css,
544                max_width_px: max_width,
545            },
546        );
547        let probed = response.and_then(|value| {
548            if !value.width.is_finite() || !value.height.is_finite() {
549                return None;
550            }
551            let line_count = value.html.match_indices("<div").count().max(1);
552            Some(ProbeCacheValue {
553                html: value.html,
554                width: value.width.max(0.0),
555                height: value.height.max(0.0),
556                line_count,
557            })
558        });
559
560        if let Some(probed_value) = probed.clone() {
561            if let Ok(mut render_cache) = self.render_cache.lock() {
562                render_cache
563                    .entry(render)
564                    .or_insert_with(|| Some(probed_value.html.clone()));
565            }
566        }
567        if let Ok(mut cache) = self.probe_cache.lock() {
568            cache.insert(key, probed.clone());
569        }
570
571        probed
572    }
573
574    fn sequence_probe_cached(&self, text: &str, config: &MermaidConfig) -> Option<ProbeCacheValue> {
575        let key = Self::render_key(text, config);
576        if let Some(cached) = self
577            .sequence_probe_cache
578            .lock()
579            .ok()
580            .and_then(|cache| cache.get(&key).cloned())
581        {
582            return cached;
583        }
584
585        let response: Option<NodeProbeResponse> = self.run_node_request(
586            "probe-sequence",
587            &NodeRenderRequest {
588                text: key.text.clone(),
589                config: NodeMathConfig {
590                    legacy_mathml: key.legacy_mathml,
591                    force_legacy_mathml: key.force_legacy_mathml,
592                },
593            },
594        );
595        let probed = response.and_then(|value| {
596            if !value.width.is_finite() || !value.height.is_finite() {
597                return None;
598            }
599            let line_count = value.html.match_indices("<div").count().max(1);
600            Some(ProbeCacheValue {
601                html: value.html,
602                width: value.width.max(0.0),
603                height: value.height.max(0.0),
604                line_count,
605            })
606        });
607
608        if let Some(probed_value) = probed.clone() {
609            if let Ok(mut render_cache) = self.render_cache.lock() {
610                render_cache
611                    .entry(key.clone())
612                    .or_insert_with(|| Some(probed_value.html.clone()));
613            }
614        }
615        if let Ok(mut cache) = self.sequence_probe_cache.lock() {
616            cache.insert(key, probed.clone());
617        }
618
619        probed
620    }
621}
622
623impl MathRenderer for NodeKatexMathRenderer {
624    fn render_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String> {
625        if !text.contains("$$") {
626            return None;
627        }
628        self.render_cached(text, config)
629    }
630
631    fn measure_html_label(
632        &self,
633        text: &str,
634        config: &MermaidConfig,
635        style: &TextStyle,
636        max_width_px: Option<f64>,
637        wrap_mode: WrapMode,
638    ) -> Option<TextMetrics> {
639        if wrap_mode != WrapMode::HtmlLike || !text.contains("$$") {
640            return None;
641        }
642        let probed = self.probe_cached(text, config, style, max_width_px, wrap_mode)?;
643        Some(TextMetrics {
644            width: crate::text::round_to_1_64_px(probed.width),
645            height: crate::text::round_to_1_64_px(probed.height),
646            line_count: probed.line_count,
647        })
648    }
649
650    fn measure_sequence_html_label(
651        &self,
652        text: &str,
653        config: &MermaidConfig,
654    ) -> Option<TextMetrics> {
655        if !text.contains("$$") {
656            return None;
657        }
658        let probed = self.sequence_probe_cached(text, config)?;
659        Some(TextMetrics {
660            width: crate::text::round_to_1_64_px(probed.width),
661            height: crate::text::round_to_1_64_px(probed.height),
662            line_count: probed.line_count,
663        })
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[cfg(feature = "ratex-math")]
672    #[test]
673    fn ratex_math_renderer_splits_math_only_labels_with_source_br_shape() {
674        assert_eq!(
675            RatexMathRenderer::math_only_lines("$$x$$<BR /> $$y$$<bR\t/>$$z$$"),
676            Some(vec!["x".to_string(), "y".to_string(), "z".to_string()])
677        );
678        assert!(
679            RatexMathRenderer::math_only_lines("$$x$$<brx>$$y$$").is_none(),
680            "non-source <br> lookalikes must not split a same-line multi-formula label"
681        );
682    }
683
684    #[cfg(feature = "ratex-math")]
685    #[test]
686    fn ratex_math_renderer_renders_pure_math_label_as_inline_svg() {
687        let renderer = RatexMathRenderer;
688        let config = MermaidConfig::from_value(serde_json::json!({ "securityLevel": "loose" }));
689
690        let html = renderer
691            .render_html_label("$$x^2$$", &config)
692            .expect("ratex should render pure math labels");
693
694        assert!(html.contains("<svg"), "expected inline SVG: {html}");
695        assert!(
696            html.contains("<path"),
697            "expected outlined glyph paths: {html}"
698        );
699        assert!(
700            html.contains(r#"width="0.97153em""#),
701            "unexpected SVG size: {html}"
702        );
703        let sanitized = merman_core::sanitize::sanitize_text(&html, &config);
704        assert!(
705            sanitized.contains("<svg") && sanitized.contains("<path"),
706            "sanitizer should preserve RaTeX inline SVG: {sanitized}"
707        );
708        let mixed_html = renderer
709            .render_html_label("value: $$x^2$$", &config)
710            .expect("RaTeX HTML rendering should support prose plus math");
711        assert!(
712            mixed_html.contains("value: ") && mixed_html.contains("<svg"),
713            "unexpected mixed math HTML: {mixed_html}"
714        );
715        assert!(
716            !mixed_html.contains("$$"),
717            "mixed math HTML should replace source delimiters: {mixed_html}"
718        );
719
720        let mixed_sequence = renderer
721            .render_sequence_html_label("value: $$x^2$$", &config)
722            .expect("Sequence RaTeX labels should support prose plus math");
723        assert!(
724            mixed_sequence.contains("value: ") && mixed_sequence.contains("<svg"),
725            "unexpected Sequence mixed math HTML: {mixed_sequence}"
726        );
727        assert!(
728            !mixed_sequence.contains("$$"),
729            "Sequence mixed math HTML should replace source delimiters: {mixed_sequence}"
730        );
731    }
732
733    #[cfg(feature = "ratex-math")]
734    #[test]
735    fn ratex_math_renderer_rejects_multiple_formulas_on_one_line() {
736        let renderer = RatexMathRenderer;
737        let config = MermaidConfig::default();
738        let style = TextStyle::default();
739        let label = "a $$x$$ b $$y$$ c";
740
741        assert!(
742            renderer.render_html_label(label, &config).is_none(),
743            "Mermaid's greedy $$...$$ regex treats same-line multiple formulas as one invalid formula, so RaTeX should not render them non-greedily"
744        );
745        assert!(
746            renderer
747                .measure_html_label(label, &config, &style, Some(200.0), WrapMode::HtmlLike)
748                .is_none(),
749            "same-line multiple formulas should remain unsupported in measurement too"
750        );
751        assert!(
752            renderer
753                .measure_sequence_html_label(label, &config)
754                .is_none(),
755            "Sequence should keep the same delimiter policy"
756        );
757    }
758
759    #[cfg(feature = "ratex-math")]
760    #[test]
761    fn ratex_math_renderer_measures_flowchart_and_sequence_math_labels() {
762        let renderer = RatexMathRenderer;
763        let config = MermaidConfig::default();
764        let style = TextStyle::default();
765
766        let flowchart = renderer
767            .measure_html_label("$$x^2$$", &config, &style, Some(200.0), WrapMode::HtmlLike)
768            .expect("ratex should measure pure flowchart math labels");
769        assert_eq!(flowchart.width, 15.546875);
770        assert_eq!(flowchart.height, 13.828125);
771        assert_eq!(flowchart.line_count, 1);
772
773        let sequence = renderer
774            .measure_sequence_html_label("$$x^2$$", &config)
775            .expect("ratex should measure pure sequence math labels");
776        assert_eq!(sequence.width, flowchart.width);
777        assert_eq!(sequence.height, flowchart.height);
778        assert_eq!(sequence.line_count, 1);
779
780        let flowchart_request = crate::flowchart::FlowchartLabelMetricsRequest {
781            measurer: &crate::text::DeterministicTextMeasurer::default(),
782            raw_label: "$$x^2$$",
783            label_type: "text",
784            style: &style,
785            max_width_px: Some(200.0),
786            wrap_mode: WrapMode::HtmlLike,
787            config: &config,
788            math_renderer: Some(&renderer),
789            preserve_string_whitespace_height: false,
790            whole_label_font_style: None,
791        };
792        let through_flowchart =
793            crate::flowchart::flowchart_label_metrics_for_layout(flowchart_request);
794        assert_eq!(through_flowchart.width, flowchart.width);
795        assert_eq!(through_flowchart.height, flowchart.height);
796    }
797
798    #[test]
799    fn node_katex_math_renderer_smoke() {
800        let node_cwd = Path::new(env!("CARGO_MANIFEST_DIR"))
801            .join("..")
802            .join("..")
803            .join("tools")
804            .join("mermaid-cli");
805        if !node_cwd.join("package.json").is_file() || !node_cwd.join("node_modules").is_dir() {
806            return;
807        }
808
809        let renderer = NodeKatexMathRenderer::new(node_cwd);
810        let config = MermaidConfig::default();
811        let style = TextStyle::default();
812
813        let Some(html) = renderer.render_html_label("$$x^2$$", &config) else {
814            return;
815        };
816        assert!(html.contains("katex"), "unexpected HTML: {html}");
817
818        let Some(metrics) = renderer.measure_html_label(
819            "$$x^2$$",
820            &config,
821            &style,
822            Some(200.0),
823            WrapMode::HtmlLike,
824        ) else {
825            return;
826        };
827        assert!(metrics.width.is_finite() && metrics.width > 0.0);
828        assert!(metrics.height.is_finite() && metrics.height > 0.0);
829    }
830
831    #[test]
832    fn node_katex_math_renderer_measures_sanitized_flowchart_browser_shell() {
833        let node_cwd = Path::new(env!("CARGO_MANIFEST_DIR"))
834            .join("..")
835            .join("..")
836            .join("tools")
837            .join("mermaid-cli");
838        if !node_cwd.join("package.json").is_file() || !node_cwd.join("node_modules").is_dir() {
839            return;
840        }
841
842        let renderer = NodeKatexMathRenderer::new(node_cwd);
843        let config = MermaidConfig::default();
844        let style = TextStyle::default();
845
846        let long_integral = "$$f(\\relax{x}) = \\int_{-\\infty}^\\infty \\hat{f}(\\xi)\\,e^{2 \\pi i \\xi x}\\,d\\xi$$";
847        let Some(node_metrics) = renderer.measure_html_label(
848            long_integral,
849            &config,
850            &style,
851            Some(200.0),
852            WrapMode::HtmlLike,
853        ) else {
854            return;
855        };
856        assert!(
857            (150.0..=260.0).contains(&node_metrics.width),
858            "node width = {}",
859            node_metrics.width
860        );
861        assert!(
862            (20.0..=70.0).contains(&node_metrics.height),
863            "node height = {}",
864            node_metrics.height
865        );
866
867        let matrix_label =
868            "$$x(t)=c_1\\begin{bmatrix}-\\cos{t}+\\sin{t}\\\\ 2\\cos{t} \\end{bmatrix}e^{2t}$$";
869        let Some(matrix_metrics) = renderer.measure_html_label(
870            matrix_label,
871            &config,
872            &style,
873            Some(200.0),
874            WrapMode::HtmlLike,
875        ) else {
876            return;
877        };
878        // This is a Node/KaTeX shell smoke, not a browser-font parity gate.
879        assert!(
880            (250.0..=290.0).contains(&matrix_metrics.width),
881            "matrix width = {}",
882            matrix_metrics.width
883        );
884        assert!(
885            (20.0..=32.0).contains(&matrix_metrics.height),
886            "matrix height = {}",
887            matrix_metrics.height
888        );
889
890        let Some(html) = renderer.render_html_label(long_integral, &config) else {
891            panic!("expected rendered math HTML after successful probe");
892        };
893        assert!(html.contains("<math"), "unexpected HTML: {html}");
894        assert!(!html.contains("<semantics>"), "unsanitized HTML: {html}");
895
896        let nested_delimiters = "$$\\Bigg(\\bigg(\\Big(\\big((\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a})\\big)\\Big)\\bigg)\\Bigg)$$";
897        let Some(edge_metrics) = renderer.measure_html_label(
898            nested_delimiters,
899            &config,
900            &style,
901            Some(200.0),
902            WrapMode::HtmlLike,
903        ) else {
904            return;
905        };
906        assert!(
907            (150.0..=320.0).contains(&edge_metrics.width),
908            "edge width = {}",
909            edge_metrics.width
910        );
911        assert!(
912            (30.0..=100.0).contains(&edge_metrics.height),
913            "edge height = {}",
914            edge_metrics.height
915        );
916    }
917}