document_graphics/
mermaid.rs1use crate::{Raster, RasterOptions, svg};
4pub const MAX_SOURCE: usize = 16 * 1024;
5
6pub fn admissible(source: &str) -> bool {
8 if source.is_empty()
9 || !source.is_ascii()
10 || source.len() > MAX_SOURCE
11 || source.lines().count() > 256
12 || source.bytes().filter(|b| *b == b';').count() > 256
13 || source.bytes().filter(|b| *b == b'&').count() > 64
14 || source.contains("%%{")
15 || source.trim_start().starts_with("---")
16 || source
17 .chars()
18 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
19 {
20 return false;
21 }
22 let header = source
23 .lines()
24 .map(str::trim)
25 .find(|s| !s.is_empty() && !s.starts_with("%%"))
26 .and_then(|s| s.split(|c: char| c.is_whitespace() || c == ';').next())
27 .unwrap_or_default();
28 if !matches!(
29 header,
30 "flowchart"
31 | "graph"
32 | "sequenceDiagram"
33 | "classDiagram"
34 | "stateDiagram"
35 | "stateDiagram-v2"
36 | "erDiagram"
37 ) {
38 return false;
39 }
40 let (mut depth, mut quoted, mut escaped) = (0usize, false, false);
42 for character in source.chars() {
43 if escaped {
44 escaped = false;
45 continue;
46 }
47 if character == '\\' {
48 escaped = true;
49 continue;
50 }
51 if character == '"' {
52 quoted = !quoted;
53 continue;
54 }
55 if quoted {
56 continue;
57 }
58 if matches!(character, '[' | '(' | '{') {
59 depth += 1;
60 if depth > 32 {
61 return false;
62 }
63 } else if matches!(character, ']' | ')' | '}') {
64 depth = depth.saturating_sub(1);
65 }
66 }
67 !matches!(header, "flowchart" | "graph") || (depth == 0 && !quoted)
68}
69
70pub(crate) fn render(source: &str, light: bool, options: RasterOptions) -> Option<Raster> {
71 use mermaid_rs_renderer::{
72 DiagramKind, LayoutConfig, Theme, compute_layout, parse_mermaid_strict, render_svg,
73 };
74 if !admissible(source) {
75 return None;
76 }
77 let parsed = parse_mermaid_strict(source).ok()?;
78 let graph = &parsed.graph;
79 if graph.kind == DiagramKind::Er && graph.nodes.values().any(|n| n.label.contains('\n')) {
82 return None;
83 }
84 if parsed.init_config.is_some()
85 || !matches!(
86 graph.kind,
87 DiagramKind::Flowchart
88 | DiagramKind::Sequence
89 | DiagramKind::Class
90 | DiagramKind::State
91 | DiagramKind::Er
92 )
93 || graph.nodes.len() > 128
94 || graph.edges.len() > 256
95 || graph.subgraphs.len() > 16
96 || graph.subgraphs.iter().map(|g| g.nodes.len()).sum::<usize>() > 512
97 || graph.sequence_participants.len() > 32
98 || graph.sequence_frames.len() > 16
99 || graph.sequence_notes.len() > 64
100 || graph.sequence_activations.len() > 128
101 || graph.sequence_boxes.len() > 16
102 || graph.state_notes.len() > 64
103 || !graph.node_links.is_empty()
104 || graph
105 .nodes
106 .values()
107 .any(|node| node.icon.is_some() || node.label.len() > 1024 || !node.label.is_ascii())
108 {
109 return None;
110 }
111 let mut theme = if light {
112 Theme::modern()
113 } else {
114 Theme::dark()
115 };
116 if !light {
117 theme.background = "#252a33".into();
118 theme.primary_color = "#243447".into();
119 theme.primary_border_color = "#63cce3".into();
120 theme.primary_text_color = "#e3eaf2".into();
121 theme.text_color = "#e3eaf2".into();
122 theme.line_color = "#b1c3d4".into();
123 theme.edge_label_background = theme.background.clone();
124 theme.sequence_actor_fill = theme.primary_color.clone();
125 theme.sequence_actor_border = theme.primary_border_color.clone();
126 }
127 theme.font_family = "KaTeX_SansSerif".into();
128 theme.font_size = 16.;
129 let config = LayoutConfig {
130 fast_text_metrics: true,
131 ..Default::default()
132 };
133 let layout = compute_layout(graph, &theme, &config);
134 let svg = render_svg(&layout, &theme, &config);
135 svg::diagram(&svg, options).ok()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::MAX_PIXELS;
142 fn render(source: &str, light: bool) -> Option<Raster> {
143 super::render(source, light, RasterOptions::scaled(2.))
144 }
145 #[test]
146 fn supported_diagrams_have_bounded_retina_rasters_in_both_themes() {
147 for source in [
148 "flowchart LR\n A[Start] --> B{Ready?}\n B -->|Yes| C[Done]\n",
149 "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Reply\n",
150 "classDiagram\n class Note {\n +String title\n }\n Note --> Reader\n",
151 "stateDiagram-v2\n [*] --> Ready\n Ready --> [*]\n",
152 "erDiagram\n BOOK ||--o{ PAGE : contains\n",
153 "flowchart TD\n subgraph Input\n A-->B\n end\n B-->C\n",
154 ] {
155 for light in [false, true] {
156 let p = render(source, light).unwrap_or_else(|| panic!("Failed: {source}"));
157 assert!(p.width > 0 && p.height > 0);
158 assert!(p.width as usize * p.height as usize <= MAX_PIXELS);
159 assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize);
160 assert!(p.rgba.as_chunks::<4>().0.iter().any(|p| p[3] > 0));
161 assert!(p.width <= p.original_width * 2 && p.height <= p.original_height * 2);
162 }
163 }
164 }
165 #[test]
166 fn unsupported_excessive_or_external_content_keeps_source() {
167 for source in [
168 "pie\n\"A\":1\n",
169 "%%{init: {}}%%\nflowchart LR\nA-->B\n",
170 "---\ntheme: dark\n---\nflowchart LR; A-->B",
171 "flowchart LR\nA\x1b[2J-->B",
172 "flowchart LR\nA[中文]-->B",
173 "flowchart LR\nA[Broken",
174 "erDiagram\n BOOK {\n string title\n }\n",
175 "flowchart LR\nA-->B\nclick A \"https://example.com\"\n",
176 ] {
177 assert!(render(source, false).is_none(), "Accepted: {source}");
178 }
179 assert!(!admissible(&format!(
180 "flowchart LR\n{}",
181 "A".repeat(MAX_SOURCE)
182 )));
183 assert!(!admissible(&format!("flowchart LR\n{}", "A;".repeat(257))));
184 assert!(!admissible(&format!(
185 "flowchart LR\nA{}B{}",
186 "[".repeat(33),
187 "]".repeat(33)
188 )));
189 let large = format!(
190 "flowchart LR\n{}",
191 (0..129)
192 .map(|i| format!("N{i}[Node {i}]\n"))
193 .collect::<String>()
194 );
195 assert!(render(&large, false).is_none());
196 assert!(render("flowchart LR\nA-->B", false).is_some());
197 }
198}