Skip to main content

yggdrasil/api/markdown/mermaid/
mod.rs

1//! Mermaid 服务端渲染(merman,Mermaid 12.1 的 Rust 实现)。
2//!
3//! 每个 ```` ```mermaid ```` 块渲染成浅色、深色两份内联 SVG:
4//! `<div class="mermaid-light">…</div><div class="mermaid-dark">…</div>`,由 CSS 按 `html.dark`
5//! 显示其一(`styles/components/mermaid.css`)。HTML 标签模式,字宽见 [`metrics`]。
6//! 渲染失败时降级为 `<pre class="mermaid-error">` 显示源码,文章照常保存。
7//!
8//! SVG 含 `<style>`、`foreignObject` 等 sanitizer 不放行的内容,所以 Markdown 管线先输出
9//! 占位符、sanitize 之后再换成 SVG([`Diagrams`]);SVG 由 merman 生成,作者文本已被其转义。
10
11mod 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
24/// 子图框重叠时 `nodeSpacing` 的最大加宽档数(每档 10px,起点 50)。
25const MAX_SPACING_STEPS: u32 = 8;
26
27/// 渲染一张图的浅色与深色两份 SVG,返回两个 `div`。
28///
29/// `ordinal` 为该图在文档中的序号,与源码摘要一起构成 SVG id,保证同页多图 id 不冲突、
30/// 同一输入结果完全确定(往返守卫依赖这一点)。
31pub(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    // 深色沿用浅色最终的间距,两份几何一致。
48    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    // 第三方解析器面对作者输入若 panic,不能让文章保存失败。
73    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
86/// 一次 Markdown 渲染中的全部图表:先写占位符,sanitize 之后再换成 SVG。
87///
88/// 占位符含随机 nonce,只在本次渲染内有效,作者在正文里写同样的文字不会被替换。
89pub(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    /// 渲染一个 mermaid 代码块;成功写占位 `<figure>`,失败降级为转义后的源码。
109    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    /// 把已 sanitize 的 HTML 中的占位符换成 SVG。
129    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
142/// 删掉深色版本,只留浅色 SVG(Feed 阅读器没有站点 CSS,不处理会同时显示两份)。
143pub(crate) fn strip_dark_variants(html: &str) -> Cow<'_, str> {
144    const OPEN: &str = r#"<div class="mermaid-dark">"#;
145    // 图表末尾特有的结束序列;只删到 `</svg></div>`,保留 `</figure>`。
146    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", &regular(16.0));
198        // SF Pro Text 的 'A' 最宽:0.673828125 em 向上取整到 0.674。
199        assert!((a - 0.674 * 16.0).abs() < 1e-9, "{a}");
200        // 西文字体都没有「中」,回退到 Noto Sans CJK 的 1 em。
201        assert!((metrics::text_width_px("中", &regular(16.0)) - 16.0).abs() < 1e-9);
202        // 基本多文种平面以外按 1 em 计。
203        assert!((metrics::text_width_px("😀", &regular(16.0)) - 16.0).abs() < 1e-9);
204        // 零宽字符不占宽度。
205        assert_eq!(metrics::text_width_px("A\u{200B}", &regular(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        // 与普通代码块一样保留围栏内容末尾的换行;源码经转义。
249        assert!(
250            html.contains("<pre class=\"mermaid-error\"><code>这不是图表 &lt;b&gt;\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    /// 时序图各消息标签的 (文字, y)。
276    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}