Skip to main content

yggdrasil/api/markdown/mermaid/
config.rs

1//! Mermaid 站点配置与配色。
2//!
3//! 用 `theme: "base"`:base 主题不硬编码颜色,themeVariables 能完全控制调色板。
4//! 颜色烤进 SVG 内联样式,不能用 CSS 变量,取值对应 `styles/theme.css` 的 token;
5//! 浅色、深色各渲染一份,由 CSS 按 `html.dark` 显示其一。
6//! 设计:节点是浮在代码块底色上的卡片(surface 色阶 + 发丝线边框),文字用 fg,连线用 fg-3,
7//! 注释用陶土浅底;强调色留给作者用 classDef 手动指定。
8//!
9//! base 主题只给通用变量,各图专用的颜色(甘特任务、ER 行、饼图、git 分支、时间线/思维导图的
10//! cScale 等)由 `primaryColor` 推导:白色节点推出的是一片灰,深色模式更会推出黑底黑字、
11//! 浅底白字。所以这里把专用变量全部显式给出;所有颜色都必须不透明,
12//! Mermaid 的 `fade()` 会直接替换 alpha(半透明的子图底色到了方块图里会变成 50% 黑)。
13
14use serde_json::{json, Map, Value};
15
16/// 一套主题的色调。
17struct Tones {
18    /// 图背景,等于代码块底色 surface-1。
19    background: &'static str,
20    node: &'static str,
21    node_alt: &'static str,
22    node_soft: &'static str,
23    border: &'static str,
24    border_strong: &'static str,
25    text: &'static str,
26    text_secondary: &'static str,
27    text_tertiary: &'static str,
28    line: &'static str,
29    /// 子图、甘特分区底色:背景上叠一层极淡的墨色(已合成为不透明色)。
30    cluster: &'static str,
31    note_background: &'static str,
32    note_border: &'static str,
33    /// 系列色:图表数据、分支、曲线(陶土、鼠尾草、石板蓝、赭黄、梅紫、青、玫瑰、橄榄)。
34    series: [&'static str; 8],
35    /// 系列色对应的分区底色(前 6 个系列),时间线、思维导图、看板、矩形树图的分区用。
36    sections: [&'static str; 6],
37    /// 系列色上的文字(git 分支名、饼图百分比)。
38    on_series: &'static str,
39    crit_fill: &'static str,
40    crit_border: &'static str,
41}
42
43/// 浅色:白色节点浮在 surface-1 上。
44const LIGHT: Tones = Tones {
45    background: "#fafaf8",
46    node: "#ffffff",
47    node_alt: "#f5f5f3",
48    node_soft: "#efefec",
49    border: "#dcdbd7",
50    border_strong: "#c4c3be",
51    text: "#151515",
52    text_secondary: "#525252",
53    text_tertiary: "#6b6b69",
54    line: "#6b6b69",
55    cluster: "#f5f5f3",
56    note_background: "#f7e8e1",
57    note_border: "#d97757",
58    series: [
59        "#d97757", "#5f8f6e", "#5b7fa6", "#c49a3c", "#93689c", "#4a9a92", "#c46b7d", "#8a8f4a",
60    ],
61    sections: [
62        "#f3ddd3", "#dce9df", "#dbe4ef", "#f1e6cc", "#e9dcec", "#d5eae8",
63    ],
64    on_series: "#ffffff",
65    crit_fill: "#fbeaea",
66    crit_border: "#c93a3a",
67};
68
69/// 深色:暖调深灰节点,surface 色阶递进。
70const DARK: Tones = Tones {
71    background: "#161615",
72    node: "#20201e",
73    node_alt: "#262624",
74    node_soft: "#1a1a19",
75    border: "#3a3936",
76    border_strong: "#4f4e4a",
77    text: "#f2f1ee",
78    text_secondary: "#a9a7a1",
79    text_tertiary: "#8b8984",
80    line: "#a9a7a1",
81    cluster: "#1d1d1c",
82    note_background: "#33251f",
83    note_border: "#d97757",
84    series: [
85        "#e08a6c", "#8fbf9f", "#8fabd0", "#ddb965", "#b993c2", "#7cc4bb", "#e0939f", "#b4b874",
86    ],
87    sections: [
88        "#4a2e24", "#2b3d31", "#2a3443", "#463c25", "#3d2f42", "#233f3d",
89    ],
90    on_series: "#161615",
91    crit_fill: "#3d2222",
92    crit_border: "#f87171",
93};
94
95/// Mermaid 的 cScale 色阶有 12 档,git 分支 8 档,饼图 12 档。
96const SCALE_LEN: usize = 12;
97const GIT_LEN: usize = 8;
98
99const FONT_FAMILY: &str = "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif";
100
101fn tones(dark: bool) -> &'static Tones {
102    if dark {
103        &DARK
104    } else {
105        &LIGHT
106    }
107}
108
109/// `radar` 为 true 时 cScale 用系列色:雷达图拿 cScale 画曲线(描边 + 半透明填充),
110/// 分区底色那样的浅色会看不见;其余图拿 cScale 画分区底色。
111fn theme_variables(t: &Tones, radar: bool) -> Value {
112    let mut v = json!({
113        "background": t.background,
114        // 节点填充:surface 色阶递进(主/次/三级),卡片质感
115        "primaryColor": t.node,
116        "secondaryColor": t.node_alt,
117        "tertiaryColor": t.node_soft,
118        "mainBkg": t.node,
119        "nodeBkg": t.node,
120        "secondBkg": t.node_alt,
121        "primaryBorderColor": t.border,
122        "secondaryBorderColor": t.border,
123        "tertiaryBorderColor": t.border,
124        "nodeBorder": t.border,
125        "clusterBorder": t.border,
126        "labelBoxBorderColor": t.border,
127        "primaryTextColor": t.text,
128        "secondaryTextColor": t.text_secondary,
129        "tertiaryTextColor": t.text_tertiary,
130        "textColor": t.text,
131        "nodeTextColor": t.text,
132        "titleColor": t.text,
133        "classText": t.text,
134        "labelTextColor": t.text,
135        "lineColor": t.line,
136        "defaultLinkColor": t.line,
137        "arrowheadColor": t.line,
138        // 时序图
139        "actorBkg": t.node,
140        "actorBorder": t.border,
141        "actorTextColor": t.text,
142        "actorLineColor": t.line,
143        "signalColor": t.line,
144        "signalTextColor": t.text,
145        "loopTextColor": t.text,
146        "sequenceNumberColor": t.background,
147        "activationBkgColor": t.node_alt,
148        "activationBorderColor": t.border_strong,
149        // 边标签 / 子图背景
150        "edgeLabelBackground": t.background,
151        "labelBoxBkgColor": t.background,
152        "clusterBkg": t.cluster,
153        // 注释:陶土浅底
154        "noteBkgColor": t.note_background,
155        "noteBorderColor": t.note_border,
156        "noteTextColor": t.text,
157        "fontFamily": FONT_FAMILY,
158        "fontSize": "16px",
159    });
160    // 各图专用变量;与通用变量分成两段,单个 json! 会超出宏递归上限。
161    let diagrams = json!({
162        // ER 属性表:奇偶行在节点色阶里交替
163        "rowOdd": t.node,
164        "rowEven": t.node_alt,
165        "attributeBackgroundColorOdd": t.node,
166        "attributeBackgroundColorEven": t.node_alt,
167        // 甘特图:分区几乎透明,普通任务蓝、进行中陶土、已完成灰、关键任务红
168        "sectionBkgColor": t.cluster,
169        "sectionBkgColor2": t.cluster,
170        "altSectionBkgColor": t.background,
171        "excludeBkgColor": t.node_soft,
172        "gridColor": t.border,
173        "todayLineColor": t.series[0],
174        "vertLineColor": t.line,
175        "taskBkgColor": t.sections[2],
176        "taskBorderColor": t.series[2],
177        "activeTaskBkgColor": t.sections[0],
178        "activeTaskBorderColor": t.series[0],
179        "doneTaskBkgColor": t.node_alt,
180        "doneTaskBorderColor": t.border_strong,
181        "critBkgColor": t.crit_fill,
182        "critBorderColor": t.crit_border,
183        "taskTextColor": t.text,
184        "taskTextLightColor": t.text,
185        "taskTextDarkColor": t.text,
186        "taskTextOutsideColor": t.text,
187        "taskTextClickableColor": t.text,
188        // git 图的提交标签与标记
189        "commitLabelColor": t.text_secondary,
190        "commitLabelBackground": t.background,
191        "tagLabelColor": t.text,
192        "tagLabelBackground": t.node,
193        "tagLabelBorder": t.border_strong,
194        // 饼图:系列色实心扇区,扇区间用背景色分隔
195        "pieStrokeColor": t.background,
196        "pieOuterStrokeColor": t.background,
197        "pieOpacity": "1",
198        "pieSectionTextColor": t.on_series,
199        "pieTitleTextColor": t.text,
200        "pieLegendTextColor": t.text,
201        // 象限图:四个象限同色,靠分隔线区分
202        "quadrant1Fill": t.node,
203        "quadrant2Fill": t.node,
204        "quadrant3Fill": t.node,
205        "quadrant4Fill": t.node,
206        "quadrantPointFill": t.series[0],
207        "quadrantPointTextFill": t.text,
208        "quadrantInternalBorderStrokeFill": t.border,
209        "quadrantExternalBorderStrokeFill": t.border_strong,
210        // 架构图
211        "archEdgeColor": t.line,
212        "archEdgeArrowColor": t.line,
213        "archEdgeWidth": "2",
214        "archGroupBorderColor": t.border_strong,
215        "xyChart": {
216            "backgroundColor": t.background,
217            "titleColor": t.text,
218            "dataLabelColor": t.text,
219            "legendTextColor": t.text,
220            "xAxisTitleColor": t.text_secondary,
221            "xAxisLabelColor": t.text_secondary,
222            "xAxisTickColor": t.border_strong,
223            "xAxisLineColor": t.border_strong,
224            "yAxisTitleColor": t.text_secondary,
225            "yAxisLabelColor": t.text_secondary,
226            "yAxisTickColor": t.border_strong,
227            "yAxisLineColor": t.border_strong,
228            "plotColorPalette": t.series.join(","),
229        },
230        "radar": {
231            "axisColor": t.line,
232            "graticuleColor": t.node_alt,
233            "graticuleOpacity": 0.5,
234            "curveOpacity": 0.3,
235        },
236    });
237    let vars = v.as_object_mut().expect("themeVariables 是对象");
238    if let Value::Object(diagrams) = diagrams {
239        vars.extend(diagrams);
240    }
241    // cScale:时间线、思维导图、看板、矩形树图的分区底色;描边、分隔线用对应系列色。
242    for i in 0..SCALE_LEN {
243        let fill = if radar {
244            t.series[i % t.series.len()]
245        } else {
246            t.sections[i % t.sections.len()]
247        };
248        let accent = t.series[i % t.sections.len()];
249        set(vars, format!("cScale{i}"), fill);
250        set(vars, format!("cScalePeer{i}"), accent);
251        set(vars, format!("cScaleInv{i}"), accent);
252        set(vars, format!("cScaleLabel{i}"), t.text);
253        set(vars, format!("pie{}", i + 1), t.series[i % t.series.len()]);
254    }
255    for i in 0..GIT_LEN {
256        set(vars, format!("git{i}"), t.series[i]);
257        // 合并提交等空心点的填充
258        set(vars, format!("gitInv{i}"), t.background);
259        set(vars, format!("gitBranchLabel{i}"), t.on_series);
260    }
261    v
262}
263
264fn set(vars: &mut Map<String, Value>, key: String, color: &str) {
265    vars.insert(key, Value::from(color));
266}
267
268/// 站点级 Mermaid 配置。
269///
270/// `node_spacing` 仅在子图框重叠、需要加大间距重排时传入;`source` 用来识别雷达图(见
271/// [`theme_variables`])。
272pub(super) fn site_config(dark: bool, node_spacing: Option<u32>, source: &str) -> Value {
273    let t = tones(dark);
274    let mut flowchart = json!({
275        "curve": "basis",
276        "diagramPadding": 16,
277        "useMaxWidth": true,
278        "htmlLabels": true,
279    });
280    if let Some(spacing) = node_spacing {
281        flowchart["nodeSpacing"] = json!(spacing);
282    }
283    json!({
284        "layout": "dagre",
285        "look": "classic",
286        "theme": "base",
287        "darkMode": dark,
288        "securityLevel": "strict",
289        "flowchart": flowchart,
290        // C4 与数据包图的颜色是图表配置而不是 themeVariables。
291        // C4 形状保留标准的蓝/灰,只把关系线与标签跟随主题(默认 #444 在深色里看不见);
292        // 形状间距从 50 加到 70,否则横向关系的标签压到两侧形状上。
293        "c4": {
294            "c4ShapeMargin": 70,
295            "relationship_text_color": t.text_secondary,
296            "relationship_line_color": t.line,
297            "boundary_border_color": t.border_strong,
298            "boundary_font_color": t.text_secondary,
299        },
300        "packet": {
301            "startByteColor": t.text_tertiary,
302            "endByteColor": t.text_tertiary,
303            "labelColor": t.text,
304            "titleColor": t.text,
305            "blockStrokeColor": t.border_strong,
306            "blockFillColor": t.node,
307        },
308        "themeVariables": theme_variables(t, is_radar(source)),
309    })
310}
311
312/// 图表类型关键字是否为雷达图;跳过空行、`%%` 注释与指令、YAML frontmatter。
313fn is_radar(source: &str) -> bool {
314    let mut lines = source
315        .lines()
316        .map(str::trim)
317        .filter(|l| !l.is_empty() && !l.starts_with("%%"));
318    let mut first = lines.next();
319    if first == Some("---") {
320        first = lines.by_ref().skip_while(|l| *l != "---").nth(1);
321    }
322    first
323        .and_then(|l| l.split_whitespace().next())
324        .is_some_and(|kind| kind == "radar-beta" || kind == "radar")
325}
326
327/// 主题变量管不到的几处颜色,追加到 SVG 自带的 `<style>`(规则以 SVG id 限定作用域)。
328///
329/// - 甘特图网格线是 d3 坐标轴的 `<line stroke="currentColor">`,会继承正文文字色(浅色下是黑线),
330///   改为继承所在 `.tick` 的 `gridColor`;
331/// - ER 图「零」基数的圆圈写死 `fill="white"`,深色下成了实心白点,改成背景色(空心);
332/// - 时序图消息标签描一圈背景色:自调用标签居中压在生命线上,跨参与者的长标签也会穿过
333///   中间的生命线,描边让线在文字处断开;
334/// - 桑基图连线用 `mix-blend-mode: multiply`(行内样式),深色背景上乘出来接近黑色,
335///   深色改用对称的 `screen`。
336pub(super) fn fixup_css(svg_id: &str, dark: bool) -> String {
337    let background = tones(dark).background;
338    let mut css = format!(
339        "#{svg_id} .grid .tick line{{stroke:inherit;}}\
340         #{svg_id} .marker.er circle{{fill:{background};}}\
341         #{svg_id} .messageText{{paint-order:stroke;stroke:{background};stroke-width:4px;stroke-linejoin:round;}}"
342    );
343    if dark {
344        css.push_str(&format!(
345            "#{svg_id} .link{{mix-blend-mode:screen!important;}}"
346        ));
347    }
348    css
349}
350
351#[cfg(all(test, feature = "server"))]
352mod tests {
353    use super::is_radar;
354
355    #[test]
356    fn radar_detection_skips_comments_and_frontmatter() {
357        assert!(is_radar("radar-beta\n  axis a"));
358        assert!(is_radar("\n%% 注释\n%%{init: {}}%%\n  radar-beta\n"));
359        assert!(is_radar("---\ntitle: 雷达\n---\nradar-beta\n"));
360        assert!(!is_radar("flowchart LR\n  radar --> b"));
361        assert!(!is_radar("---\nradar-beta\n---\npie\n"));
362        assert!(!is_radar(""));
363    }
364}