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