1#[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
21pub trait MathRenderer: std::fmt::Debug {
28 fn render_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String>;
35
36 fn render_sequence_html_label(&self, text: &str, config: &MermaidConfig) -> Option<String> {
41 self.render_html_label(text, config)
42 }
43
44 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 fn measure_sequence_html_label(
65 &self,
66 _text: &str,
67 _config: &MermaidConfig,
68 ) -> Option<TextMetrics> {
69 None
70 }
71}
72
73#[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
230pub(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
249pub(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#[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 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#[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 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}