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