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 models math rendering as an operation-scoped backend. An operation without
5//! one rejects typed diagrams that contain delimited math instead of emitting the source as plain
6//! text. The `math` feature installs the pure-Rust RaTeX backend by default; hosts may still supply
7//! another implementation explicitly.
8
9#[cfg(feature = "math")]
10use crate::text::split_html_br_lines;
11use crate::text::{TextMetrics, TextStyle, WrapMode};
12use merman_core::MermaidConfig;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::fmt::Write as _;
16use std::io::Write as _;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19use std::sync::Mutex;
20
21/// Optional math renderer used to transform label HTML and (optionally) provide measurements.
22///
23/// Implementations should be:
24/// - deterministic (stable output across runs),
25/// - side-effect free (no global mutations),
26/// - non-panicking (return `None` to decline handling).
27pub trait MathRenderer: std::fmt::Debug {
28    /// Attempts to render math fragments within an HTML label string.
29    ///
30    /// If the renderer declines to handle the input, it should return `None`.
31    ///
32    /// The returned string is treated as raw HTML and will still be sanitized by merman before
33    /// emitting into an SVG `<foreignObject>`.
34    fn render_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String>;
35
36    /// Attempts to render a Sequence `drawKatex(...)` label.
37    ///
38    /// Sequence uses a bare `foreignObject` with `width: fit-content` rather than Flowchart's
39    /// HTML-label shell, so math backends may support a slightly different surface here.
40    fn render_sequence_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String> {
41        self.render_html_label(text, config)
42    }
43
44    /// Optionally measures the rendered HTML label in pixels.
45    ///
46    /// This is intended to mirror upstream Mermaid's DOM measurement behavior for math labels.
47    /// The default implementation returns `None`.
48    fn measure_html_label(
49        &self,
50        _text: &str,
51        _config: &MermaidConfig,
52        _style: &TextStyle,
53        _max_width_px: Option<f64>,
54        _wrap_mode: WrapMode,
55    ) -> Option<TextMetrics> {
56        None
57    }
58
59    /// Optionally measures a Sequence `drawKatex(...)` label in pixels.
60    ///
61    /// Mermaid Sequence does not wrap KaTeX labels in the flowchart HTML-label shell; it appends
62    /// a bare `<foreignObject><div style="width: fit-content;">...</div></foreignObject>`.
63    /// This hook lets Sequence callers avoid inheriting flowchart-specific table-cell metrics.
64    fn measure_sequence_html_label(
65        &self,
66        _text: &str,
67        _config: &MermaidConfig,
68    ) -> Option<TextMetrics> {
69        None
70    }
71}
72
73/// Explicit no-op math renderer for hosts that want a backend which declines every label.
74#[derive(Debug, Default, Clone, Copy)]
75pub struct NoopMathRenderer;
76
77impl MathRenderer for NoopMathRenderer {
78    fn render_html_label(&self, _text: &str, _config: &MermaidConfig) -> Option<String> {
79        None
80    }
81}
82
83#[derive(Debug, Clone, Copy)]
84pub(crate) struct DelimitedMathFragment<'a> {
85    pub(crate) leading_text: &'a str,
86    #[cfg(feature = "math")]
87    pub(crate) formula: &'a str,
88    pub(crate) delimited: &'a str,
89}
90
91#[derive(Debug)]
92pub(crate) struct DelimitedMathLine<'a> {
93    pub(crate) fragments: Vec<DelimitedMathFragment<'a>>,
94    pub(crate) trailing_text: &'a str,
95}
96
97pub(crate) fn parse_delimited_math_line(text: &str) -> Option<DelimitedMathLine<'_>> {
98    let mut fragments = Vec::new();
99    let mut search_from = 0usize;
100
101    while let Some(start_rel) = text[search_from..].find("$$") {
102        let start = search_from + start_rel;
103        let content_start = start + 2;
104        let Some(end_rel) = text[content_start..].find("$$") else {
105            break;
106        };
107        let end_start = content_start + end_rel;
108        let end = end_start + 2;
109        fragments.push(DelimitedMathFragment {
110            leading_text: &text[search_from..start],
111            #[cfg(feature = "math")]
112            formula: &text[content_start..end_start],
113            delimited: &text[start..end],
114        });
115        search_from = end;
116    }
117
118    (!fragments.is_empty()).then_some(DelimitedMathLine {
119        fragments,
120        trailing_text: &text[search_from..],
121    })
122}
123
124pub(crate) fn contains_delimited_math(text: &str) -> bool {
125    crate::text::split_html_br_lines(text)
126        .into_iter()
127        .any(|line| parse_delimited_math_line(line).is_some())
128}
129
130pub(crate) struct MathLabelMetricsRequest<'a> {
131    pub(crate) measurer: &'a dyn crate::text::TextMeasurer,
132    pub(crate) raw_label: &'a str,
133    pub(crate) style: &'a TextStyle,
134    pub(crate) max_width_px: Option<f64>,
135    pub(crate) wrap_mode: WrapMode,
136    pub(crate) config: &'a MermaidConfig,
137    pub(crate) math_renderer: Option<&'a (dyn MathRenderer + Send + Sync)>,
138}
139
140fn measure_text_fragment(
141    measurer: &dyn crate::text::TextMeasurer,
142    text: &str,
143    style: &TextStyle,
144) -> TextMetrics {
145    measurer.measure_wrapped(text, style, None, WrapMode::HtmlLike)
146}
147
148fn measure_mixed_math_line(
149    measurer: &dyn crate::text::TextMeasurer,
150    line: &str,
151    style: &TextStyle,
152    config: &MermaidConfig,
153    math_renderer: &(dyn MathRenderer + Send + Sync),
154) -> Option<(f64, f64)> {
155    let start = line.find("$$")?;
156    let content_start = start + 2;
157    let end_start = line[content_start..].rfind("$$")? + content_start;
158    if line[content_start..end_start].contains("$$") {
159        return None;
160    }
161
162    let mut width = 0.0_f64;
163    let mut height = 0.0_f64;
164    for text in [&line[..start], &line[end_start + 2..]] {
165        if text.is_empty() {
166            continue;
167        }
168        let metrics = measure_text_fragment(measurer, text, style);
169        width += metrics.width.max(0.0);
170        height = height.max(metrics.height.max(0.0));
171    }
172
173    let math_metrics = math_renderer.measure_html_label(
174        &line[start..end_start + 2],
175        config,
176        style,
177        Some(10_000.0),
178        WrapMode::HtmlLike,
179    )?;
180    width += math_metrics.width.max(0.0);
181    height = height.max(math_metrics.height.max(0.0));
182
183    Some((width, height.max(1.0)))
184}
185
186fn measure_mixed_math_label(
187    request: &MathLabelMetricsRequest<'_>,
188    math_renderer: &(dyn MathRenderer + Send + Sync),
189) -> Option<TextMetrics> {
190    if !request.raw_label.contains("$$") {
191        return None;
192    }
193    math_renderer.render_html_label(request.raw_label, request.config)?;
194
195    let mut saw_math = false;
196    let mut width = 0.0_f64;
197    let mut height = 0.0_f64;
198    let mut line_count = 0usize;
199    for line in crate::text::split_html_br_lines(request.raw_label) {
200        line_count += 1;
201        let (line_width, line_height) = if line.contains("$$") {
202            saw_math = true;
203            measure_mixed_math_line(
204                request.measurer,
205                line,
206                request.style,
207                request.config,
208                math_renderer,
209            )?
210        } else {
211            let metrics = request.measurer.measure_wrapped(
212                line,
213                request.style,
214                request.max_width_px,
215                WrapMode::HtmlLike,
216            );
217            (metrics.width.max(0.0), metrics.height.max(0.0))
218        };
219        width = width.max(line_width);
220        height += line_height;
221    }
222
223    saw_math.then_some(TextMetrics {
224        width,
225        height: height.max(1.0),
226        line_count: line_count.max(1),
227    })
228}
229
230/// Measures a Mermaid HTML label after delegating its math fragments to the active backend.
231pub(crate) fn math_label_metrics_for_layout(
232    request: MathLabelMetricsRequest<'_>,
233) -> Option<TextMetrics> {
234    if request.wrap_mode != WrapMode::HtmlLike || !request.raw_label.contains("$$") {
235        return None;
236    }
237    let math_renderer = request.math_renderer?;
238    math_renderer
239        .measure_html_label(
240            request.raw_label,
241            request.config,
242            request.style,
243            request.max_width_px,
244            request.wrap_mode,
245        )
246        .or_else(|| measure_mixed_math_label(&request, math_renderer))
247}
248
249/// Renders, sanitizes, and XML-normalizes a shared Mermaid math label fragment.
250pub(crate) fn render_math_html_label(
251    text: &str,
252    config: &MermaidConfig,
253    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
254) -> Option<String> {
255    if !text.contains("$$") {
256        return None;
257    }
258    let rendered = math_renderer?.render_html_label(text, config)?;
259    Some(crate::xml::normalize_html_fragment_for_xhtml(
260        &merman_core::sanitize::sanitize_text(&rendered, config),
261    ))
262}
263
264/// Pure-Rust math renderer backed by RaTeX.
265///
266/// The first Flowchart surface is intentionally narrow: labels where each non-empty line is a
267/// single `$$...$$` formula. Sequence additionally supports formulas embedded in surrounding
268/// prose, matching Mermaid's `drawKatex(...)` shell.
269#[cfg(feature = "math")]
270#[derive(Debug, Default, Clone, Copy)]
271pub struct RatexMathRenderer;
272
273#[cfg(feature = "math")]
274#[derive(Debug, Clone)]
275struct RatexMathMetrics {
276    width_em: f64,
277    height_em: f64,
278    line_count: usize,
279}
280
281#[cfg(feature = "math")]
282impl RatexMathRenderer {
283    fn normalized_text(text: &str) -> String {
284        text.replace("\\\\", "\\")
285    }
286
287    fn math_only_lines(text: &str) -> Option<Vec<String>> {
288        let normalized = Self::normalized_text(text);
289        let mut formulas = Vec::new();
290        for raw_line in split_html_br_lines(&normalized) {
291            let line = raw_line.trim();
292            if line.is_empty() {
293                continue;
294            }
295            let inner = line.strip_prefix("$$")?.strip_suffix("$$")?;
296            if inner.contains("$$") {
297                return None;
298            }
299            formulas.push(inner.to_string());
300        }
301        if formulas.is_empty() {
302            None
303        } else {
304            Some(formulas)
305        }
306    }
307
308    fn layout_formula_em(latex: &str) -> Option<(ratex_types::DisplayList, f64, f64)> {
309        let ast = ratex_parser::parse(latex).ok()?;
310        let layout_options = ratex_layout::LayoutOptions::default()
311            .with_style(ratex_types::MathStyle::Display)
312            .with_color(ratex_types::Color::BLACK);
313        let layout_box = ratex_layout::layout(&ast, &layout_options);
314        // Display-list bounds include overflowing ink (for example, smash and lap commands).
315        // Measurement must use those same rounded bounds as the emitted SVG viewport.
316        let display_list = ratex_layout::to_display_list(&layout_box);
317        let width_em = Self::emitted_em_dimension(display_list.width.max(0.0));
318        let height_em = Self::emitted_em_dimension(display_list.total_height().max(0.0));
319        Some((display_list, width_em, height_em))
320    }
321
322    fn render_formula_svg_em(latex: &str) -> Option<(String, f64, f64)> {
323        let (display_list, width_em, height_em) = Self::layout_formula_em(latex)?;
324        let svg = ratex_svg::render_to_svg(
325            &display_list,
326            &ratex_svg::SvgOptions {
327                font_size: 1.0,
328                padding: 0.0,
329                stroke_width: 0.04,
330                embed_glyphs: true,
331                font_dir: String::new(),
332            },
333        );
334        Some((
335            Self::svg_with_em_size(svg, width_em, height_em),
336            width_em,
337            height_em,
338        ))
339    }
340
341    fn svg_with_em_size(svg: String, width_em: f64, height_em: f64) -> String {
342        let Some(open_end) = svg.find('>') else {
343            return svg;
344        };
345        let Some(body_with_close) = svg.get(open_end + 1..) else {
346            return svg;
347        };
348        let Some(body) = body_with_close.strip_suffix("</svg>") else {
349            return svg;
350        };
351        let width = Self::fmt_num(width_em);
352        let height = Self::fmt_num(height_em);
353        format!(
354            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}em" height="{height}em">{body}</svg>"#
355        )
356    }
357
358    fn measure_math_only_label(text: &str) -> Option<RatexMathMetrics> {
359        let formulas = Self::math_only_lines(text)?;
360        let mut width_em: f64 = 0.0;
361        let mut height_em: f64 = 0.0;
362        let mut line_count = 0usize;
363        for formula in formulas {
364            let (_display_list, line_width_em, line_height_em) = Self::layout_formula_em(&formula)?;
365            width_em = width_em.max(line_width_em);
366            height_em += line_height_em;
367            line_count += 1;
368        }
369        Some(RatexMathMetrics {
370            width_em,
371            height_em,
372            line_count: line_count.max(1),
373        })
374    }
375
376    fn render_katex_like_line_html(parsed: DelimitedMathLine<'_>) -> Option<String> {
377        let mut html = String::new();
378        for fragment in parsed.fragments {
379            let (svg, _width_em, _height_em) = Self::render_formula_svg_em(fragment.formula)?;
380            html.push_str(fragment.leading_text);
381            html.push_str(&svg);
382        }
383        html.push_str(parsed.trailing_text);
384        Some(html)
385    }
386
387    fn render_katex_like_label(text: &str) -> Option<String> {
388        let normalized = Self::normalized_text(text);
389        if !normalized.contains("$$") {
390            return None;
391        }
392
393        let mut html = String::new();
394        let mut saw_math = false;
395        for line in split_html_br_lines(&normalized) {
396            if let Some(parsed) = parse_delimited_math_line(line) {
397                saw_math = true;
398                let rendered_line = Self::render_katex_like_line_html(parsed)?;
399                let _ = write!(
400                    &mut html,
401                    r#"<div style="display: flex; align-items: center; justify-content: center; white-space: nowrap;">{rendered_line}</div>"#
402                );
403            } else {
404                let _ = write!(&mut html, "<div>{line}</div>");
405            }
406        }
407
408        saw_math.then_some(html)
409    }
410
411    fn metrics_from_em(measured: &RatexMathMetrics, font_size: f64) -> TextMetrics {
412        let font_size = font_size.max(1.0);
413        TextMetrics {
414            width: measured.width_em * font_size,
415            height: measured.height_em * font_size,
416            line_count: measured.line_count,
417        }
418    }
419
420    fn emitted_em_dimension(value: f64) -> f64 {
421        Self::fmt_num(value).parse().unwrap_or(0.0)
422    }
423
424    fn fmt_num(n: f64) -> String {
425        let s = format!("{n:.6}");
426        let s = s.trim_end_matches('0').trim_end_matches('.');
427        if s.is_empty() || s == "-" {
428            "0".to_string()
429        } else {
430            s.to_string()
431        }
432    }
433}
434
435#[cfg(feature = "math")]
436impl MathRenderer for RatexMathRenderer {
437    fn render_html_label(&self, text: &str, _config: &MermaidConfig) -> Option<String> {
438        if !text.contains("$$") {
439            return None;
440        }
441        Self::render_katex_like_label(text)
442    }
443
444    fn render_sequence_html_label(&self, text: &str, _config: &MermaidConfig) -> Option<String> {
445        Self::render_katex_like_label(text)
446    }
447
448    fn measure_html_label(
449        &self,
450        text: &str,
451        _config: &MermaidConfig,
452        style: &TextStyle,
453        _max_width_px: Option<f64>,
454        wrap_mode: WrapMode,
455    ) -> Option<TextMetrics> {
456        if wrap_mode != WrapMode::HtmlLike || !text.contains("$$") {
457            return None;
458        }
459        let measured = Self::measure_math_only_label(text)?;
460        Some(Self::metrics_from_em(&measured, style.font_size))
461    }
462
463    fn measure_sequence_html_label(
464        &self,
465        text: &str,
466        _config: &MermaidConfig,
467    ) -> Option<TextMetrics> {
468        if !text.contains("$$") {
469            return None;
470        }
471        let measured = Self::measure_math_only_label(text)?;
472        Some(Self::metrics_from_em(
473            &measured,
474            TextStyle::default().font_size,
475        ))
476    }
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Hash)]
480struct RenderCacheKey {
481    text: String,
482    legacy_mathml: bool,
483    force_legacy_mathml: bool,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Hash)]
487struct ProbeCacheKey {
488    render: RenderCacheKey,
489    font_family: Option<String>,
490    font_size_bits: u64,
491    font_weight: Option<String>,
492    max_width_bits: u64,
493}
494
495#[derive(Debug, Clone)]
496struct ProbeCacheValue {
497    html: String,
498    width: f64,
499    height: f64,
500    line_count: usize,
501}
502
503#[derive(Debug, Serialize)]
504struct NodeRenderRequest {
505    text: String,
506    config: NodeMathConfig,
507}
508
509#[derive(Debug, Serialize)]
510struct NodeProbeRequest {
511    text: String,
512    config: NodeMathConfig,
513    #[serde(rename = "styleCss")]
514    style_css: String,
515    #[serde(rename = "maxWidthPx")]
516    max_width_px: f64,
517}
518
519#[derive(Debug, Serialize)]
520struct NodeMathConfig {
521    #[serde(rename = "legacyMathML")]
522    legacy_mathml: bool,
523    #[serde(rename = "forceLegacyMathML")]
524    force_legacy_mathml: bool,
525}
526
527#[derive(Debug, Deserialize)]
528struct NodeRenderResponse {
529    html: String,
530}
531
532#[derive(Debug, Deserialize)]
533struct NodeProbeResponse {
534    html: String,
535    width: f64,
536    height: f64,
537}
538
539/// Optional KaTeX backend that shells out to a local Node.js toolchain.
540///
541/// This backend is intended for parity work where a real browser DOM is available. It mirrors
542/// Mermaid's flowchart HTML-label KaTeX path closely by:
543/// - rendering KaTeX through the local `katex` npm package, and
544/// - measuring the wrapped `<foreignObject>` HTML through local `puppeteer`.
545///
546/// The backend is completely opt-in; if the configured Node.js environment is unavailable or the
547/// probe fails, it simply returns `None` and lets callers fall back to the default text path.
548#[derive(Debug)]
549pub struct NodeKatexMathRenderer {
550    node_cwd: PathBuf,
551    node_command: PathBuf,
552    render_cache: Mutex<HashMap<RenderCacheKey, Option<String>>>,
553    probe_cache: Mutex<HashMap<ProbeCacheKey, Option<ProbeCacheValue>>>,
554    sequence_probe_cache: Mutex<HashMap<RenderCacheKey, Option<ProbeCacheValue>>>,
555}
556
557impl NodeKatexMathRenderer {
558    pub fn new(node_cwd: impl Into<PathBuf>) -> Self {
559        Self {
560            node_cwd: node_cwd.into(),
561            node_command: PathBuf::from("node"),
562            render_cache: Mutex::new(HashMap::new()),
563            probe_cache: Mutex::new(HashMap::new()),
564            sequence_probe_cache: Mutex::new(HashMap::new()),
565        }
566    }
567
568    pub fn with_node_command(mut self, node_command: impl Into<PathBuf>) -> Self {
569        self.node_command = node_command.into();
570        self
571    }
572
573    fn script_path() -> PathBuf {
574        Path::new(env!("CARGO_MANIFEST_DIR"))
575            .join("assets")
576            .join("katex_flowchart_probe.cjs")
577    }
578
579    fn normalized_text(text: &str) -> String {
580        text.replace("\\\\", "\\")
581    }
582
583    fn math_config(config: &MermaidConfig) -> NodeMathConfig {
584        let config_value = config.as_value();
585        let legacy_mathml = config_value
586            .get("legacyMathML")
587            .and_then(serde_json::Value::as_bool)
588            .unwrap_or(false);
589        let force_legacy_mathml = config_value
590            .get("forceLegacyMathML")
591            .and_then(serde_json::Value::as_bool)
592            .unwrap_or(false);
593        NodeMathConfig {
594            legacy_mathml,
595            force_legacy_mathml,
596        }
597    }
598
599    fn render_key(text: &str, config: &MermaidConfig) -> RenderCacheKey {
600        let config = Self::math_config(config);
601        RenderCacheKey {
602            text: Self::normalized_text(text),
603            legacy_mathml: config.legacy_mathml,
604            force_legacy_mathml: config.force_legacy_mathml,
605        }
606    }
607
608    fn style_css(style: &TextStyle) -> String {
609        let mut out = String::new();
610        let font_family = style
611            .font_family
612            .as_deref()
613            .unwrap_or("\"trebuchet ms\",verdana,arial,sans-serif");
614        let _ = write!(&mut out, "font-size: {}px;", style.font_size);
615        let _ = write!(&mut out, "font-family: {};", font_family);
616        if let Some(font_weight) = style.font_weight.as_deref()
617            && !font_weight.trim().is_empty()
618        {
619            let _ = write!(&mut out, "font-weight: {};", font_weight.trim());
620        }
621        out
622    }
623
624    fn run_node_request<T, R>(&self, mode: &str, payload: &T) -> Option<R>
625    where
626        T: Serialize,
627        R: for<'de> Deserialize<'de>,
628    {
629        if !self.node_cwd.join("package.json").is_file() {
630            return None;
631        }
632
633        let mut child = Command::new(&self.node_command)
634            .arg(Self::script_path())
635            .arg(mode)
636            .current_dir(&self.node_cwd)
637            .stdin(Stdio::piped())
638            .stdout(Stdio::piped())
639            .stderr(Stdio::piped())
640            .spawn()
641            .ok()?;
642
643        if let Some(mut stdin) = child.stdin.take() {
644            if serde_json::to_writer(&mut stdin, payload).is_err() {
645                return None;
646            }
647            let _ = stdin.flush();
648        }
649
650        let output = child.wait_with_output().ok()?;
651        if !output.status.success() {
652            return None;
653        }
654
655        serde_json::from_slice(&output.stdout).ok()
656    }
657
658    fn render_cached(&self, text: &str, config: &MermaidConfig) -> Option<String> {
659        let key = Self::render_key(text, config);
660        if let Some(cached) = self
661            .render_cache
662            .lock()
663            .ok()
664            .and_then(|cache| cache.get(&key).cloned())
665        {
666            return cached;
667        }
668
669        let response: Option<NodeRenderResponse> = self.run_node_request(
670            "render",
671            &NodeRenderRequest {
672                text: key.text.clone(),
673                config: NodeMathConfig {
674                    legacy_mathml: key.legacy_mathml,
675                    force_legacy_mathml: key.force_legacy_mathml,
676                },
677            },
678        );
679        let html = response.map(|value| value.html);
680
681        if let Ok(mut cache) = self.render_cache.lock() {
682            cache.insert(key, html.clone());
683        }
684
685        html
686    }
687
688    fn probe_cached(
689        &self,
690        text: &str,
691        config: &MermaidConfig,
692        style: &TextStyle,
693        max_width_px: Option<f64>,
694        _wrap_mode: WrapMode,
695    ) -> Option<ProbeCacheValue> {
696        let render = Self::render_key(text, config);
697        let max_width = max_width_px.unwrap_or(200.0).max(1.0);
698        let key = ProbeCacheKey {
699            render: render.clone(),
700            font_family: style.font_family.clone(),
701            font_size_bits: style.font_size.to_bits(),
702            font_weight: style.font_weight.clone(),
703            max_width_bits: max_width.to_bits(),
704        };
705        if let Some(cached) = self
706            .probe_cache
707            .lock()
708            .ok()
709            .and_then(|cache| cache.get(&key).cloned())
710        {
711            return cached;
712        }
713
714        let style_css = Self::style_css(style);
715        let response: Option<NodeProbeResponse> = self.run_node_request(
716            "probe",
717            &NodeProbeRequest {
718                text: render.text.clone(),
719                config: NodeMathConfig {
720                    legacy_mathml: render.legacy_mathml,
721                    force_legacy_mathml: render.force_legacy_mathml,
722                },
723                style_css,
724                max_width_px: max_width,
725            },
726        );
727        let probed = response.and_then(|value| {
728            if !value.width.is_finite() || !value.height.is_finite() {
729                return None;
730            }
731            let line_count = value.html.match_indices("<div").count().max(1);
732            Some(ProbeCacheValue {
733                html: value.html,
734                width: value.width.max(0.0),
735                height: value.height.max(0.0),
736                line_count,
737            })
738        });
739
740        if let Some(probed_value) = probed.clone()
741            && let Ok(mut render_cache) = self.render_cache.lock()
742        {
743            render_cache
744                .entry(render)
745                .or_insert_with(|| Some(probed_value.html.clone()));
746        }
747        if let Ok(mut cache) = self.probe_cache.lock() {
748            cache.insert(key, probed.clone());
749        }
750
751        probed
752    }
753
754    fn sequence_probe_cached(&self, text: &str, config: &MermaidConfig) -> Option<ProbeCacheValue> {
755        let key = Self::render_key(text, config);
756        if let Some(cached) = self
757            .sequence_probe_cache
758            .lock()
759            .ok()
760            .and_then(|cache| cache.get(&key).cloned())
761        {
762            return cached;
763        }
764
765        let response: Option<NodeProbeResponse> = self.run_node_request(
766            "probe-sequence",
767            &NodeRenderRequest {
768                text: key.text.clone(),
769                config: NodeMathConfig {
770                    legacy_mathml: key.legacy_mathml,
771                    force_legacy_mathml: key.force_legacy_mathml,
772                },
773            },
774        );
775        let probed = response.and_then(|value| {
776            if !value.width.is_finite() || !value.height.is_finite() {
777                return None;
778            }
779            let line_count = value.html.match_indices("<div").count().max(1);
780            Some(ProbeCacheValue {
781                html: value.html,
782                width: value.width.max(0.0),
783                height: value.height.max(0.0),
784                line_count,
785            })
786        });
787
788        if let Some(probed_value) = probed.clone()
789            && let Ok(mut render_cache) = self.render_cache.lock()
790        {
791            render_cache
792                .entry(key.clone())
793                .or_insert_with(|| Some(probed_value.html.clone()));
794        }
795        if let Ok(mut cache) = self.sequence_probe_cache.lock() {
796            cache.insert(key, probed.clone());
797        }
798
799        probed
800    }
801}
802
803impl MathRenderer for NodeKatexMathRenderer {
804    fn render_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String> {
805        if !text.contains("$$") {
806            return None;
807        }
808        self.render_cached(text, config)
809    }
810
811    fn measure_html_label(
812        &self,
813        text: &str,
814        config: &MermaidConfig,
815        style: &TextStyle,
816        max_width_px: Option<f64>,
817        wrap_mode: WrapMode,
818    ) -> Option<TextMetrics> {
819        if wrap_mode != WrapMode::HtmlLike || !text.contains("$$") {
820            return None;
821        }
822        let probed = self.probe_cached(text, config, style, max_width_px, wrap_mode)?;
823        Some(TextMetrics {
824            width: probed.width,
825            height: probed.height,
826            line_count: probed.line_count,
827        })
828    }
829
830    fn measure_sequence_html_label(
831        &self,
832        text: &str,
833        config: &MermaidConfig,
834    ) -> Option<TextMetrics> {
835        if !text.contains("$$") {
836            return None;
837        }
838        let probed = self.sequence_probe_cached(text, config)?;
839        Some(TextMetrics {
840            width: probed.width,
841            height: probed.height,
842            line_count: probed.line_count,
843        })
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850
851    #[test]
852    fn delimited_math_detection_requires_a_complete_pair_on_one_label_line() {
853        assert!(contains_delimited_math("value: $$x^2$$"));
854        assert!(contains_delimited_math("plain<br>value: $$x^2$$"));
855        assert!(!contains_delimited_math("literal $$"));
856        assert!(!contains_delimited_math("literal $$<br>literal $$"));
857    }
858
859    #[cfg(feature = "math")]
860    #[test]
861    fn ratex_math_renderer_splits_math_only_labels_with_source_br_shape() {
862        assert_eq!(
863            RatexMathRenderer::math_only_lines("$$x$$<BR /> $$y$$<bR\t/>$$z$$"),
864            Some(vec!["x".to_string(), "y".to_string(), "z".to_string()])
865        );
866        assert!(
867            RatexMathRenderer::math_only_lines("$$x$$<brx>$$y$$").is_none(),
868            "non-source <br> lookalikes must not split a same-line multi-formula label"
869        );
870    }
871
872    #[cfg(feature = "math")]
873    #[test]
874    fn ratex_math_metrics_preserve_emitted_em_precision() {
875        let rendered = RatexMathMetrics {
876            width_em: 0.6255,
877            height_em: 1.2505,
878            line_count: 1,
879        };
880
881        let metrics = RatexMathRenderer::metrics_from_em(&rendered, 16.0);
882
883        assert_eq!(metrics.width, rendered.width_em * 16.0);
884        assert_eq!(metrics.height, rendered.height_em * 16.0);
885    }
886
887    #[cfg(feature = "math")]
888    #[test]
889    fn ratex_math_renderer_renders_pure_math_label_as_inline_svg() {
890        let renderer = RatexMathRenderer;
891        let config = MermaidConfig::from_value(serde_json::json!({ "securityLevel": "loose" }));
892
893        let html = renderer
894            .render_html_label("$$x^2$$", &config)
895            .expect("ratex should render pure math labels");
896
897        assert!(html.contains("<svg"), "expected inline SVG: {html}");
898        assert!(
899            html.contains("<path"),
900            "expected outlined glyph paths: {html}"
901        );
902        assert!(
903            html.contains(r#"width="0.97153em""#),
904            "unexpected SVG size: {html}"
905        );
906        let sanitized = merman_core::sanitize::sanitize_text(&html, &config);
907        assert!(
908            sanitized.contains("<svg") && sanitized.contains("<path"),
909            "sanitizer should preserve RaTeX inline SVG: {sanitized}"
910        );
911        let mixed_html = renderer
912            .render_html_label("value: $$x^2$$", &config)
913            .expect("RaTeX HTML rendering should support prose plus math");
914        assert!(
915            mixed_html.contains("value: ") && mixed_html.contains("<svg"),
916            "unexpected mixed math HTML: {mixed_html}"
917        );
918        assert!(
919            !mixed_html.contains("$$"),
920            "mixed math HTML should replace source delimiters: {mixed_html}"
921        );
922
923        let mixed_sequence = renderer
924            .render_sequence_html_label("value: $$x^2$$", &config)
925            .expect("Sequence RaTeX labels should support prose plus math");
926        assert!(
927            mixed_sequence.contains("value: ") && mixed_sequence.contains("<svg"),
928            "unexpected Sequence mixed math HTML: {mixed_sequence}"
929        );
930        assert!(
931            !mixed_sequence.contains("$$"),
932            "Sequence mixed math HTML should replace source delimiters: {mixed_sequence}"
933        );
934    }
935
936    #[cfg(feature = "math")]
937    #[test]
938    fn ratex_math_renderer_renders_at_depth_limit_and_declines_deeper_input() {
939        let renderer = RatexMathRenderer;
940        let config = MermaidConfig::default();
941        let nested_label =
942            |depth: usize| format!("$${}x{}$$", "{".repeat(depth), "}".repeat(depth));
943
944        let at_limit = nested_label(32);
945        let html = renderer
946            .render_html_label(&at_limit, &config)
947            .expect("RaTeX should render input at its supported logical depth limit");
948        assert!(html.contains("<svg"), "expected inline SVG: {html}");
949
950        for depth in [33, 300] {
951            let over_limit = nested_label(depth);
952            assert!(
953                renderer.render_html_label(&over_limit, &config).is_none(),
954                "depth {depth} should be declined for plain-text fallback without panicking"
955            );
956        }
957    }
958
959    #[cfg(feature = "math")]
960    #[test]
961    fn ratex_math_renderer_renders_multiple_formulas_on_one_line_independently() {
962        let renderer = RatexMathRenderer;
963        let config = MermaidConfig::default();
964        let label = "a $$x$$ b $$y$$ c";
965
966        let html = renderer
967            .render_html_label(label, &config)
968            .expect("same-line formulas should render");
969        assert_eq!(
970            html.matches(r#"<svg xmlns="http://www.w3.org/2000/svg""#)
971                .count(),
972            2,
973            "each non-greedy math fragment should render independently: {html}"
974        );
975        assert!(
976            html.contains("a ") && html.contains(" b ") && html.contains(" c"),
977            "plain text between formulas should be preserved: {html}"
978        );
979        assert!(
980            !html.contains("$$"),
981            "all complete math delimiters should be replaced: {html}"
982        );
983
984        let sequence_html = renderer
985            .render_sequence_html_label(label, &config)
986            .expect("Sequence should share the non-greedy delimiter policy");
987        assert_eq!(
988            sequence_html
989                .matches(r#"<svg xmlns="http://www.w3.org/2000/svg""#)
990                .count(),
991            2,
992            "Sequence should render both same-line formulas: {sequence_html}"
993        );
994    }
995
996    #[cfg(feature = "math")]
997    #[test]
998    fn ratex_math_renderer_preserves_unclosed_delimiters_on_plain_lines() {
999        let renderer = RatexMathRenderer;
1000        let config = MermaidConfig::default();
1001
1002        assert!(
1003            renderer
1004                .render_sequence_html_label("literal $$", &config)
1005                .is_none(),
1006            "an unmatched delimiter alone must not enable Sequence math rendering"
1007        );
1008
1009        let label = "valid $$x$$<br>literal $$";
1010
1011        let html = renderer
1012            .render_sequence_html_label(label, &config)
1013            .expect("the complete formula should still render");
1014
1015        assert_eq!(
1016            html.matches(r#"<svg xmlns="http://www.w3.org/2000/svg""#)
1017                .count(),
1018            1,
1019            "only the complete formula should render: {html}"
1020        );
1021        assert!(
1022            html.contains("<div>literal $$</div>"),
1023            "the unmatched delimiter should remain plain text: {html}"
1024        );
1025    }
1026
1027    #[cfg(feature = "math")]
1028    #[test]
1029    fn ratex_math_measurements_match_emitted_svg_dimensions() {
1030        let renderer = RatexMathRenderer;
1031        let config = MermaidConfig::default();
1032        let style = TextStyle::default();
1033        let (svg, width_em, height_em) = RatexMathRenderer::render_formula_svg_em("x^2")
1034            .expect("ratex should emit the formula SVG");
1035        assert!(
1036            svg.contains(&format!(
1037                "width=\"{}em\"",
1038                RatexMathRenderer::fmt_num(width_em)
1039            )),
1040            "unexpected emitted SVG width: {svg}"
1041        );
1042        assert!(
1043            svg.contains(&format!(
1044                "height=\"{}em\"",
1045                RatexMathRenderer::fmt_num(height_em)
1046            )),
1047            "unexpected emitted SVG height: {svg}"
1048        );
1049
1050        let flowchart = renderer
1051            .measure_html_label("$$x^2$$", &config, &style, Some(200.0), WrapMode::HtmlLike)
1052            .expect("ratex should measure pure flowchart math labels");
1053        assert_eq!(flowchart.width, width_em * style.font_size);
1054        assert_eq!(flowchart.height, height_em * style.font_size);
1055        assert_eq!(flowchart.line_count, 1);
1056
1057        let sequence = renderer
1058            .measure_sequence_html_label("$$x^2$$", &config)
1059            .expect("ratex should measure pure sequence math labels");
1060        assert_eq!(sequence.width, flowchart.width);
1061        assert_eq!(sequence.height, flowchart.height);
1062        assert_eq!(sequence.line_count, 1);
1063
1064        let flowchart_request = crate::flowchart::FlowchartLabelMetricsRequest {
1065            measurer: &crate::text::DeterministicTextMeasurer::default(),
1066            raw_label: "$$x^2$$",
1067            label_type: "text",
1068            style: &style,
1069            max_width_px: Some(200.0),
1070            wrap_mode: WrapMode::HtmlLike,
1071            config: &config,
1072            math_renderer: Some(&renderer),
1073        };
1074        let through_flowchart =
1075            crate::flowchart::flowchart_label_metrics_for_layout(flowchart_request);
1076        assert_eq!(through_flowchart.width, flowchart.width);
1077        assert_eq!(through_flowchart.height, flowchart.height);
1078    }
1079
1080    #[cfg(feature = "math")]
1081    #[test]
1082    fn ratex_math_measurements_match_svg_viewports_across_formulas_and_lines() {
1083        let renderer = RatexMathRenderer;
1084        let config = MermaidConfig::default();
1085        for label in [
1086            "$$x^2$$",
1087            r"$$\frac{1}{\sqrt{x}}$$",
1088            "$$α+β$$",
1089            r"$$\text{中文}$$",
1090            r"$$\smash{x^2}$$",
1091            r"$$\mathllap{x^2}$$",
1092            r"$$x^2$$<BR />$$\frac{1}{\sqrt{x}}$$<br>$$α+β$$",
1093        ] {
1094            let html = renderer.render_html_label(label, &config).unwrap();
1095            let document_source = format!("<div>{html}</div>");
1096            let document = roxmltree::Document::parse(&document_source).unwrap();
1097            let mut width_em = 0.0_f64;
1098            let mut height_em = 0.0_f64;
1099            let mut line_count = 0;
1100            for svg in document
1101                .descendants()
1102                .filter(|node| node.has_tag_name("svg"))
1103            {
1104                let dimension = |name| {
1105                    svg.attribute(name)
1106                        .unwrap()
1107                        .strip_suffix("em")
1108                        .unwrap()
1109                        .parse::<f64>()
1110                        .unwrap()
1111                };
1112                width_em = width_em.max(dimension("width"));
1113                height_em += dimension("height");
1114                line_count += 1;
1115            }
1116            assert!(line_count > 0, "expected formula SVGs for {label}");
1117
1118            for font_size in [0.5, 16.0, 23.75] {
1119                let style = TextStyle {
1120                    font_size,
1121                    ..TextStyle::default()
1122                };
1123                let metrics = renderer
1124                    .measure_html_label(label, &config, &style, Some(1.0), WrapMode::HtmlLike)
1125                    .unwrap();
1126                assert_eq!(metrics.width, width_em * font_size.max(1.0), "{label}");
1127                assert_eq!(metrics.height, height_em * font_size.max(1.0), "{label}");
1128                assert_eq!(metrics.line_count, line_count, "{label}");
1129            }
1130
1131            let sequence = renderer
1132                .measure_sequence_html_label(label, &config)
1133                .unwrap();
1134            let font_size = TextStyle::default().font_size;
1135            assert_eq!(sequence.width, width_em * font_size, "{label}");
1136            assert_eq!(sequence.height, height_em * font_size, "{label}");
1137            assert_eq!(sequence.line_count, line_count, "{label}");
1138        }
1139    }
1140
1141    #[cfg(feature = "math")]
1142    #[test]
1143    fn ratex_math_measurement_preserves_declined_labels() {
1144        let renderer = RatexMathRenderer;
1145        let config = MermaidConfig::default();
1146        let style = TextStyle::default();
1147        for label in ["$$x", r"$$\frac{1}{$$", r"$$x$$<br>$$\frac{1}{$$"] {
1148            assert!(
1149                renderer.render_html_label(label, &config).is_none(),
1150                "{label}"
1151            );
1152            assert!(
1153                renderer
1154                    .measure_html_label(label, &config, &style, None, WrapMode::HtmlLike)
1155                    .is_none(),
1156                "{label}"
1157            );
1158            assert!(
1159                renderer
1160                    .measure_sequence_html_label(label, &config)
1161                    .is_none(),
1162                "{label}"
1163            );
1164        }
1165        for label in ["value: $$x$$", "$$x$$ $$y$$"] {
1166            assert!(
1167                renderer.render_html_label(label, &config).is_some(),
1168                "{label}"
1169            );
1170            assert!(
1171                renderer
1172                    .measure_html_label(label, &config, &style, None, WrapMode::HtmlLike)
1173                    .is_none(),
1174                "mixed labels must remain delegated to the existing fallback: {label}"
1175            );
1176            assert!(
1177                renderer
1178                    .measure_sequence_html_label(label, &config)
1179                    .is_none()
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn node_katex_math_renderer_smoke() {
1186        let node_cwd = Path::new(env!("CARGO_MANIFEST_DIR"))
1187            .join("..")
1188            .join("..")
1189            .join("tools")
1190            .join("mermaid-cli");
1191        if !node_cwd.join("package.json").is_file() || !node_cwd.join("node_modules").is_dir() {
1192            return;
1193        }
1194
1195        let renderer = NodeKatexMathRenderer::new(node_cwd);
1196        let config = MermaidConfig::default();
1197        let style = TextStyle::default();
1198
1199        let Some(html) = renderer.render_html_label("$$x^2$$", &config) else {
1200            return;
1201        };
1202        assert!(html.contains("katex"), "unexpected HTML: {html}");
1203
1204        let Some(metrics) = renderer.measure_html_label(
1205            "$$x^2$$",
1206            &config,
1207            &style,
1208            Some(200.0),
1209            WrapMode::HtmlLike,
1210        ) else {
1211            return;
1212        };
1213        assert!(metrics.width.is_finite() && metrics.width > 0.0);
1214        assert!(metrics.height.is_finite() && metrics.height > 0.0);
1215    }
1216
1217    #[test]
1218    fn node_katex_metrics_preserve_browser_probe_precision() {
1219        let renderer = NodeKatexMathRenderer::new("missing-node-environment");
1220        let config = MermaidConfig::default();
1221        let style = TextStyle::default();
1222        let render = NodeKatexMathRenderer::render_key("$$x$$", &config);
1223        let probe = ProbeCacheValue {
1224            html: "<div>x</div>".to_string(),
1225            width: 10.008,
1226            height: 20.008,
1227            line_count: 1,
1228        };
1229        let key = ProbeCacheKey {
1230            render: render.clone(),
1231            font_family: style.font_family.clone(),
1232            font_size_bits: style.font_size.to_bits(),
1233            font_weight: style.font_weight.clone(),
1234            max_width_bits: 200.0_f64.to_bits(),
1235        };
1236        renderer
1237            .probe_cache
1238            .lock()
1239            .unwrap()
1240            .insert(key, Some(probe.clone()));
1241        renderer
1242            .sequence_probe_cache
1243            .lock()
1244            .unwrap()
1245            .insert(render, Some(probe));
1246
1247        let flowchart = renderer
1248            .measure_html_label("$$x$$", &config, &style, Some(200.0), WrapMode::HtmlLike)
1249            .unwrap();
1250        let sequence = renderer
1251            .measure_sequence_html_label("$$x$$", &config)
1252            .unwrap();
1253
1254        assert_eq!(flowchart.width, 10.008);
1255        assert_eq!(flowchart.height, 20.008);
1256        assert_eq!(sequence.width, 10.008);
1257        assert_eq!(sequence.height, 20.008);
1258    }
1259
1260    #[test]
1261    fn node_katex_math_renderer_measures_sanitized_flowchart_browser_shell() {
1262        let node_cwd = Path::new(env!("CARGO_MANIFEST_DIR"))
1263            .join("..")
1264            .join("..")
1265            .join("tools")
1266            .join("mermaid-cli");
1267        if !node_cwd.join("package.json").is_file() || !node_cwd.join("node_modules").is_dir() {
1268            return;
1269        }
1270
1271        let renderer = NodeKatexMathRenderer::new(node_cwd);
1272        let config = MermaidConfig::default();
1273        let style = TextStyle::default();
1274
1275        let long_integral = "$$f(\\relax{x}) = \\int_{-\\infty}^\\infty \\hat{f}(\\xi)\\,e^{2 \\pi i \\xi x}\\,d\\xi$$";
1276        let Some(node_metrics) = renderer.measure_html_label(
1277            long_integral,
1278            &config,
1279            &style,
1280            Some(200.0),
1281            WrapMode::HtmlLike,
1282        ) else {
1283            return;
1284        };
1285        assert!(
1286            (150.0..=260.0).contains(&node_metrics.width),
1287            "node width = {}",
1288            node_metrics.width
1289        );
1290        assert!(
1291            (20.0..=70.0).contains(&node_metrics.height),
1292            "node height = {}",
1293            node_metrics.height
1294        );
1295
1296        let matrix_label =
1297            "$$x(t)=c_1\\begin{bmatrix}-\\cos{t}+\\sin{t}\\\\ 2\\cos{t} \\end{bmatrix}e^{2t}$$";
1298        let Some(matrix_metrics) = renderer.measure_html_label(
1299            matrix_label,
1300            &config,
1301            &style,
1302            Some(200.0),
1303            WrapMode::HtmlLike,
1304        ) else {
1305            return;
1306        };
1307        // This is a Node/KaTeX shell smoke, not a browser-font parity gate.
1308        assert!(
1309            (250.0..=290.0).contains(&matrix_metrics.width),
1310            "matrix width = {}",
1311            matrix_metrics.width
1312        );
1313        assert!(
1314            (20.0..=32.0).contains(&matrix_metrics.height),
1315            "matrix height = {}",
1316            matrix_metrics.height
1317        );
1318
1319        let Some(html) = renderer.render_html_label(long_integral, &config) else {
1320            panic!("expected rendered math HTML after successful probe");
1321        };
1322        assert!(html.contains("<math"), "unexpected HTML: {html}");
1323        assert!(!html.contains("<semantics>"), "unsanitized HTML: {html}");
1324
1325        let nested_delimiters = "$$\\Bigg(\\bigg(\\Big(\\big((\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a})\\big)\\Big)\\bigg)\\Bigg)$$";
1326        let Some(edge_metrics) = renderer.measure_html_label(
1327            nested_delimiters,
1328            &config,
1329            &style,
1330            Some(200.0),
1331            WrapMode::HtmlLike,
1332        ) else {
1333            return;
1334        };
1335        assert!(
1336            (150.0..=320.0).contains(&edge_metrics.width),
1337            "edge width = {}",
1338            edge_metrics.width
1339        );
1340        assert!(
1341            (30.0..=100.0).contains(&edge_metrics.height),
1342            "edge height = {}",
1343            edge_metrics.height
1344        );
1345    }
1346}