yggdrasil/api/markdown/mermaid/
mod.rs1mod config;
12mod metrics;
13mod svg;
14
15use std::borrow::Cow;
16use std::panic::{catch_unwind, AssertUnwindSafe};
17
18use merman::svg::SvgRenderOptions;
19use merman::{
20 Engine, MermaidConfig, OperationControl, RenderOutput, RenderRequest, Renderer, SvgRequest,
21};
22use sha2::{Digest, Sha256};
23
24const MAX_SPACING_STEPS: u32 = 8;
26
27pub(crate) fn render_diagram(source: &str, ordinal: usize) -> Result<String, String> {
32 let hash = hex::encode(&Sha256::digest(source.as_bytes())[..4]);
33 let svg_id = |variant: &str| {
34 merman::svg::sanitize_svg_id(&format!("mermaid-{hash}-{ordinal}-{variant}"))
35 };
36 let light_id = svg_id("l");
37 let mut light = render_one(false, None, source, &light_id)?;
38 let mut node_spacing = None;
39 for step in 1..=MAX_SPACING_STEPS {
40 if !svg::has_cluster_overlap(&light) {
41 break;
42 }
43 let spacing = 50 + 10 * step;
44 light = render_one(false, Some(spacing), source, &light_id)?;
45 node_spacing = Some(spacing);
46 }
47 let dark_id = svg_id("d");
49 let dark = render_one(true, node_spacing, source, &dark_id)?;
50 Ok(format!(
51 r#"<div class="mermaid-light">{}</div><div class="mermaid-dark">{}</div>"#,
52 svg::finish(&light, &config::fixup_css(&light_id, false)),
53 svg::finish(&dark, &config::fixup_css(&dark_id, true))
54 ))
55}
56
57fn render_one(
58 dark: bool,
59 node_spacing: Option<u32>,
60 source: &str,
61 id: &str,
62) -> Result<String, String> {
63 let config = MermaidConfig::from_value(config::site_config(dark, node_spacing, source));
64 let request = SvgRequest {
65 environment: metrics::environment(),
66 options: SvgRenderOptions {
67 diagram_id: Some(id.to_string()),
68 ..Default::default()
69 },
70 ..Default::default()
71 };
72 let result = catch_unwind(AssertUnwindSafe(|| {
74 Renderer::new()
75 .with_engine(Engine::new().with_site_config(config))
76 .render(RenderRequest::svg(source, OperationControl::new(), request))
77 }));
78 match result {
79 Ok(Ok(RenderOutput::Svg(Some(svg)))) => Ok(svg.svg().to_string()),
80 Ok(Ok(_)) => Err("未识别的 Mermaid 图表类型".to_string()),
81 Ok(Err(e)) => Err(e.to_string()),
82 Err(_) => Err("图表渲染器内部错误".to_string()),
83 }
84}
85
86pub(super) struct Diagrams {
90 nonce: String,
91 seen: usize,
92 rendered: Vec<String>,
93}
94
95impl Diagrams {
96 pub(super) fn new() -> Self {
97 Self {
98 nonce: uuid::Uuid::new_v4().simple().to_string(),
99 seen: 0,
100 rendered: Vec::new(),
101 }
102 }
103
104 fn marker(&self, index: usize) -> String {
105 format!("ygg-mermaid-{}-{index}-end", self.nonce)
106 }
107
108 pub(super) fn push(&mut self, html: &mut String, source: &str) {
110 let result = render_diagram(source, self.seen);
111 self.seen += 1;
112 match result {
113 Ok(inner) => {
114 html.push_str(r#"<figure class="mermaid-diagram">"#);
115 html.push_str(&self.marker(self.rendered.len()));
116 html.push_str("</figure>");
117 self.rendered.push(inner);
118 }
119 Err(e) => {
120 tracing::warn!(error = %e, "Mermaid 图表渲染失败");
121 html.push_str(r#"<pre class="mermaid-error"><code>"#);
122 html.push_str(&crate::utils::html::escape_html(source));
123 html.push_str("</code></pre>");
124 }
125 }
126 }
127
128 pub(super) fn substitute(self, mut html: String) -> String {
130 for (index, inner) in self.rendered.iter().enumerate() {
131 let marker = self.marker(index);
132 if let Some(at) = html.find(&marker) {
133 html.replace_range(at..at + marker.len(), inner);
134 } else {
135 tracing::error!(index, "Mermaid 占位符在清理后丢失");
136 }
137 }
138 html
139 }
140}
141
142pub(crate) fn strip_dark_variants(html: &str) -> Cow<'_, str> {
144 const OPEN: &str = r#"<div class="mermaid-dark">"#;
145 const CLOSE: &str = "</svg></div></figure>";
147 const KEEP: usize = "</figure>".len();
148 if !html.contains(OPEN) {
149 return Cow::Borrowed(html);
150 }
151 let mut out = String::with_capacity(html.len());
152 let mut rest = html;
153 while let Some(start) = rest.find(OPEN) {
154 let Some(close) = rest[start..].find(CLOSE) else {
155 break;
156 };
157 out.push_str(&rest[..start]);
158 rest = &rest[start + close + CLOSE.len() - KEEP..];
159 }
160 out.push_str(rest);
161 Cow::Owned(out)
162}
163
164#[cfg(all(test, feature = "server"))]
165mod tests {
166 use super::*;
167 use crate::api::markdown::render_markdown_enhanced;
168 use merman::svg::TextStyle;
169
170 const OVERLAP_FIXTURE: &str = r#"flowchart TD
171 subgraph CargoChef [Cargo Chef 3 阶段缓存物理分层]
172 Planner[Stage 1: Planner<br/>生成 recipe.json 依赖清单] --> Cooker
173 Cooker[Stage 2: Cooker 阶段<br/>仅根据 recipe.json 编译第三方依赖 .rlib] --> Builder
174 Builder[Stage 3: Builder 阶段<br/>注入应用源码 src/ 进行应用链接]
175 end
176
177 subgraph CacheMechanism ["BuildKit + GHA Cache (mode=max)"]
178 CookerCache[(GHA Cache Server<br/>scope=amd64 / arm64<br/>缓存完整 .rlib 中间产物)]
179 Cooker <-->|源码未改动依赖时秒级命中| CookerCache
180 end
181
182 style CargoChef fill:#0f172a,stroke:#334155,color:#fff
183 style CacheMechanism fill:#1e1b4b,stroke:#6366f1,color:#fff
184"#;
185
186 const SIMPLE: &str = "```mermaid\nflowchart LR\n A[开始] --> B[结束]\n```";
187
188 fn regular(size: f64) -> TextStyle {
189 TextStyle {
190 font_size: size,
191 ..Default::default()
192 }
193 }
194
195 #[test]
196 fn text_width_takes_widest_font_and_falls_back() {
197 let a = metrics::text_width_px("A", ®ular(16.0));
198 assert!((a - 0.674 * 16.0).abs() < 1e-9, "{a}");
200 assert!((metrics::text_width_px("中", ®ular(16.0)) - 16.0).abs() < 1e-9);
202 assert!((metrics::text_width_px("😀", ®ular(16.0)) - 16.0).abs() < 1e-9);
204 assert_eq!(metrics::text_width_px("A\u{200B}", ®ular(16.0)), a);
206 let bold = TextStyle {
207 font_weight: Some("700".into()),
208 ..regular(16.0)
209 };
210 assert!(metrics::text_width_px("A", &bold) > a);
211 }
212
213 fn split_variants(inner: &str) -> (&str, &str) {
214 let dark_at = inner.find(r#"<div class="mermaid-dark">"#).unwrap();
215 (&inner[..dark_at], &inner[dark_at..])
216 }
217
218 fn view_box(svg: &str) -> &str {
219 let at = svg.find("viewBox=\"").unwrap() + "viewBox=\"".len();
220 svg[at..].split('"').next().unwrap()
221 }
222
223 #[test]
224 fn pipeline_inlines_both_variants_after_sanitize() {
225 let html = render_markdown_enhanced(SIMPLE).html;
226 assert!(
227 html.contains(r#"<figure class="mermaid-diagram">"#),
228 "{html}"
229 );
230 assert!(html.contains(r#"class="mermaid-light""#));
231 assert!(html.contains(r#"class="mermaid-dark""#));
232 assert_eq!(html.matches("<svg").count(), 2);
233 assert!(
234 html.matches("<style").count() >= 2,
235 "SVG 样式被 sanitizer 剥掉"
236 );
237 assert!(!html.contains("ygg-mermaid-"));
238 assert_eq!(
239 html,
240 render_markdown_enhanced(SIMPLE).html,
241 "渲染结果须确定"
242 );
243 }
244
245 #[test]
246 fn invalid_diagram_degrades_to_source() {
247 let html = render_markdown_enhanced("```mermaid\n这不是图表 <b>\n```").html;
248 assert!(
250 html.contains("<pre class=\"mermaid-error\"><code>这不是图表 <b>\n</code></pre>"),
251 "{html}"
252 );
253 assert!(!html.contains("mermaid-diagram"));
254 }
255
256 #[test]
257 fn overlapping_clusters_get_wider_spacing_and_lifted_titles() {
258 let inner = render_diagram(OVERLAP_FIXTURE, 0).unwrap();
259 let (light, dark) = split_variants(&inner);
260 assert!(!svg::has_cluster_overlap(light));
261 assert!(!svg::has_cluster_overlap(dark));
262 assert_eq!(view_box(light), view_box(dark));
263 for variant in [light, dark] {
264 for group in variant.split(r#"<g class="clusters">"#).skip(1) {
265 if let Some(at) = group.find(r#"<g class="cluster-label""#) {
266 assert!(
267 !group[at..].contains(r#"<g class="cluster" id="#),
268 "子图标题之后不应再有子图底框"
269 );
270 }
271 }
272 }
273 }
274
275 fn message_labels(svg: &str) -> Vec<(&str, f64)> {
277 svg.match_indices("<text ")
278 .filter_map(|(at, _)| {
279 let open_end = at + svg[at..].find('>')?;
280 let open = &svg[at..open_end];
281 if !open.contains(r#"class="messageText""#) {
282 return None;
283 }
284 let y = &open[open.find(r#" y=""#)? + r#" y=""#.len()..];
285 let y = y[..y.find('"')?].parse().ok()?;
286 let text = &svg[open_end + 1..open_end + svg[open_end..].find("</text>")?];
287 Some((text, y))
288 })
289 .collect()
290 }
291
292 #[test]
293 fn self_message_labels_move_up_others_stay() {
294 const SEQ: &str =
295 "sequenceDiagram\n autonumber\n A->>B: 普通消息\n B->>B: 自调用<br/>第二行\n B->>A: 返回\n";
296 let raw = render_one(false, None, SEQ, "t").unwrap();
297 let before = message_labels(&raw);
298 let fixed = svg::finish(&raw, "");
299 let after = message_labels(&fixed);
300 assert_eq!(before.len(), 4, "{before:?}");
301 for ((text, y0), (_, y1)) in before.iter().zip(&after) {
302 let lift = if matches!(*text, "自调用" | "第二行") {
303 8.0
304 } else {
305 0.0
306 };
307 assert_eq!(y0 - y1, lift, "{text}");
308 }
309 }
310
311 #[test]
312 fn strip_dark_variants_keeps_light_svg() {
313 let html = render_markdown_enhanced(SIMPLE).html;
314 let stripped = strip_dark_variants(&html);
315 assert!(!stripped.contains("mermaid-dark"));
316 assert!(!stripped.contains(r#"-d""#));
317 let light_start = html.find(r#"<div class="mermaid-light">"#).unwrap();
318 let light_end = html.find(r#"<div class="mermaid-dark">"#).unwrap();
319 assert!(stripped.contains(&html[light_start..light_end]));
320 assert!(stripped.ends_with("</figure>"), "{stripped}");
321 assert!(matches!(
322 strip_dark_variants("<p>无图</p>"),
323 Cow::Borrowed(_)
324 ));
325 }
326}