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