1use 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.len() > MAX_SOURCE
10 || source.lines().count() > 256
11 || source.bytes().filter(|b| *b == b';').count() > 256
12 || source.bytes().filter(|b| *b == b'&').count() > 64
13 || source.contains("%%{")
14 || source.trim_start().starts_with("---")
15 || source
16 .chars()
17 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
18 {
19 return false;
20 }
21 let header = source
22 .lines()
23 .map(str::trim)
24 .find(|s| !s.is_empty() && !s.starts_with("%%"))
25 .and_then(|s| s.split(|c: char| c.is_whitespace() || c == ';').next())
26 .unwrap_or_default();
27 if !matches!(
28 header,
29 "flowchart"
30 | "graph"
31 | "sequenceDiagram"
32 | "classDiagram"
33 | "stateDiagram"
34 | "stateDiagram-v2"
35 | "erDiagram"
36 | "pie"
37 | "mindmap"
38 | "timeline"
39 | "journey"
40 | "quadrantChart"
41 ) {
42 return false;
43 }
44 let (mut depth, mut quoted, mut escaped) = (0usize, false, false);
46 for character in source.chars() {
47 if escaped {
48 escaped = false;
49 continue;
50 }
51 if character == '\\' {
52 escaped = true;
53 continue;
54 }
55 if character == '"' {
56 quoted = !quoted;
57 continue;
58 }
59 if quoted {
60 continue;
61 }
62 if matches!(character, '[' | '(' | '{') {
63 depth += 1;
64 if depth > 32 {
65 return false;
66 }
67 } else if matches!(character, ']' | ')' | '}') {
68 depth = depth.saturating_sub(1);
69 }
70 }
71 !matches!(header, "flowchart" | "graph") || (depth == 0 && !quoted)
72}
73
74pub(crate) fn render(source: &str, light: bool, options: RasterOptions) -> Option<Raster> {
75 let source = diagram_svg(source, light)?;
76 svg::diagram(&source, options).ok()
77}
78
79fn diagram_svg(source: &str, light: bool) -> Option<String> {
80 use mermaid_rs_renderer::{
81 DiagramKind, LayoutConfig, Theme, compute_layout, parse_mermaid_strict, render_svg,
82 };
83 if !admissible(source) {
84 return None;
85 }
86 let parsed = parse_mermaid_strict(source).ok()?;
87 let graph = &parsed.graph;
88 if parsed.init_config.is_some()
89 || !matches!(
90 graph.kind,
91 DiagramKind::Flowchart
92 | DiagramKind::Sequence
93 | DiagramKind::Class
94 | DiagramKind::State
95 | DiagramKind::Er
96 | DiagramKind::Pie
97 | DiagramKind::Mindmap
98 | DiagramKind::Timeline
99 | DiagramKind::Journey
100 | DiagramKind::Quadrant
101 )
102 || graph.nodes.len() > 128
103 || graph.edges.len() > 256
104 || graph.subgraphs.len() > 16
105 || graph.subgraphs.iter().map(|g| g.nodes.len()).sum::<usize>() > 512
106 || graph.sequence_participants.len() > 32
107 || graph.sequence_frames.len() > 16
108 || graph.sequence_notes.len() > 64
109 || graph.sequence_activations.len() > 128
110 || graph.sequence_boxes.len() > 16
111 || graph.state_notes.len() > 64
112 || !graph.node_links.is_empty()
113 || graph
114 .nodes
115 .values()
116 .any(|node| node.icon.is_some() || node.label.len() > 1024)
117 {
118 return None;
119 }
120 if !additional_structure_is_bounded(graph) {
121 return None;
122 }
123 static FONTS_READY: std::sync::Once = std::sync::Once::new();
124 FONTS_READY.call_once(|| mermaid_rs_renderer::set_font_database((**svg::fonts(true)).clone()));
125 let mut theme = if light {
126 Theme::modern()
127 } else {
128 Theme::dark()
129 };
130 if !light {
131 theme.background = "#252a33".into();
132 theme.primary_color = "#243447".into();
133 theme.primary_border_color = "#63cce3".into();
134 theme.primary_text_color = "#e3eaf2".into();
135 theme.text_color = "#e3eaf2".into();
136 theme.line_color = "#b1c3d4".into();
137 theme.edge_label_background = theme.background.clone();
138 theme.sequence_actor_fill = theme.primary_color.clone();
139 theme.sequence_actor_border = theme.primary_border_color.clone();
140 }
141 theme.font_family = if source.is_ascii() {
142 "KaTeX_SansSerif"
143 } else {
144 "Noto Sans"
145 }
146 .into();
147 theme.font_size = 16.;
148 if graph.kind == DiagramKind::Pie {
149 theme.pie_colors = [
150 "#67c9de", "#b3a1e8", "#f5bc73", "#9bd59a", "#ed9eac", "#91b8ee", "#e5cb82", "#d0a5ce",
151 "#88cebb", "#c2cb89", "#ecb399", "#9fbac8",
152 ]
153 .map(str::to_owned);
154 theme.pie_opacity = 1.;
155 theme.pie_section_text_color = "#172033".into();
156 theme.pie_title_text_color = theme.text_color.clone();
157 theme.pie_legend_text_color = theme.text_color.clone();
158 theme.pie_outer_stroke_color = theme.line_color.clone();
159 theme.pie_stroke_color = theme.background.clone();
160 }
161 let mut config = LayoutConfig {
162 fast_text_metrics: true,
163 ..Default::default()
164 };
165 if graph.kind == DiagramKind::Er && graph.nodes.values().any(|n| n.label.contains('\n')) {
166 config.max_label_width_chars = 1024;
169 config.node_padding_x = 48.;
170 }
171 let layout = compute_layout(graph, &theme, &config);
172 let svg = render_svg(&layout, &theme, &config);
173 if graph.kind == DiagramKind::Er && !er_content_is_complete(graph, &svg) {
174 return None;
175 }
176 Some(svg)
177}
178
179fn er_content_is_complete(graph: &mermaid_rs_renderer::Graph, svg: &str) -> bool {
183 let Ok(xml) = roxmltree::Document::parse(svg) else {
184 return false;
185 };
186 let mut actual = std::collections::HashMap::<&str, usize>::new();
187 for node in xml.descendants().filter(|n| n.is_text()) {
188 for token in node
189 .text()
190 .unwrap_or_default()
191 .split_whitespace()
192 .flat_map(|s| s.split(','))
193 .map(|s| s.trim_matches(';'))
194 {
195 *actual.entry(token).or_default() += 1;
196 }
197 }
198 for node in graph.nodes.values() {
199 for token in node
200 .label
201 .lines()
202 .filter(|s| *s != "---")
203 .flat_map(str::split_whitespace)
204 .flat_map(|s| s.split(','))
205 {
206 let token = token.trim_matches(';');
207 if token.is_empty() {
208 continue;
209 }
210 let Some(count) = actual.get_mut(token) else {
211 return false;
212 };
213 if *count == 0 {
214 return false;
215 }
216 *count -= 1;
217 }
218 }
219 true
220}
221
222fn additional_structure_is_bounded(graph: &mermaid_rs_renderer::Graph) -> bool {
223 if graph.kind == mermaid_rs_renderer::DiagramKind::Journey {
224 let mut actors = std::collections::HashSet::new();
225 for node in graph.nodes.values() {
226 if !node.value.is_some_and(|score| (1. ..=5.).contains(&score)) {
227 return false;
228 }
229 actors.extend(
230 node.label
231 .lines()
232 .skip(1)
233 .flat_map(|line| line.split(','))
234 .map(str::trim),
235 );
236 }
237 if actors.len() > 32 {
238 return false;
239 }
240 }
241 let label = |s: &str| s.len() <= 1024;
242 graph.pie_slices.len() <= 64
243 && graph
244 .pie_slices
245 .iter()
246 .all(|s| label(&s.label) && s.value.is_finite() && s.value >= 0.)
247 && (graph.pie_slices.is_empty() || {
248 let sum: f32 = graph.pie_slices.iter().map(|s| s.value).sum();
249 sum.is_finite() && sum > 0.
250 })
251 && graph.mindmap.nodes.len() <= 128
252 && graph
253 .mindmap
254 .nodes
255 .iter()
256 .all(|n| n.level <= 32 && n.icon.is_none() && label(&n.label))
257 && graph.timeline.sections.is_empty()
258 && graph.timeline.events.len() <= 64
259 && graph
260 .timeline
261 .events
262 .iter()
263 .map(|e| e.events.len())
264 .sum::<usize>()
265 <= 128
266 && graph
267 .timeline
268 .events
269 .iter()
270 .all(|e| label(&e.time) && e.events.iter().all(|s| label(s)))
271 && graph.quadrant.points.len() <= 128
272 && graph
273 .quadrant
274 .points
275 .iter()
276 .all(|p| label(&p.label) && (0. ..=1.).contains(&p.x) && (0. ..=1.).contains(&p.y))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::MAX_PIXELS;
283 fn render(source: &str, light: bool) -> Option<Raster> {
284 super::render(source, light, RasterOptions::scaled(2.))
285 }
286 #[test]
287 fn supported_diagrams_have_bounded_retina_rasters_in_both_themes() {
288 for source in [
289 "flowchart LR\n A[Start] --> B{Ready?}\n B -->|Yes| C[Done]\n",
290 "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Reply\n",
291 "classDiagram\n class Note {\n +String title\n }\n Note --> Reader\n",
292 "stateDiagram-v2\n [*] --> Ready\n Ready --> [*]\n",
293 "erDiagram\n BOOK ||--o{ PAGE : contains\n",
294 "flowchart TD\n subgraph Input\n A-->B\n end\n B-->C\n",
295 ] {
296 for light in [false, true] {
297 let p = render(source, light).unwrap_or_else(|| panic!("Failed: {source}"));
298 assert!(p.width > 0 && p.height > 0);
299 assert!(p.width as usize * p.height as usize <= MAX_PIXELS);
300 assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize);
301 assert!(p.rgba.as_chunks::<4>().0.iter().any(|p| p[3] > 0));
302 assert!(p.width <= p.original_width * 2 && p.height <= p.original_height * 2);
303 }
304 }
305 }
306 #[test]
307 fn unicode_attributes_and_more_diagrams_render_without_system_fonts() {
308 for source in [
309 "flowchart LR\n A[Café déjà vu] --> B[Ελληνικά Русский]",
310 "flowchart LR\n A[Cafe\u{301}] --> B[naïve façade]",
311 "sequenceDiagram\n participant A as André\n participant B as Zoë\n A->>B: prêt ?",
312 "erDiagram\n BOOK {\n string title\n int id PK,FK,UK\n string résumé \"détail, précis\"\n }",
313 "pie showData\n title Café\n \"Thé\": 3\n \"Café\": 7",
314 "mindmap\n root((Reading))\n Notes\n Ideas\n Diagrams",
315 "timeline\n title History\n 2020 : Launch : Learn\n 2021 : Grow",
316 "journey\n title Reading\n section Start\n Open file: 5: Reader\n Read: 4: Reader, Editor",
317 "quadrantChart\n title Work\n x-axis Low effort --> High effort\n y-axis Low value --> High value\n Task A: [0.2, 0.8]\n Task B: [0.8, 0.3]",
318 ] {
319 for light in [false, true] {
320 assert!(render(source, light).is_some(), "Failed: {source}");
321 }
322 }
323 }
324
325 #[test]
326 fn er_tables_preserve_types_keys_and_comments() {
327 let source = "erDiagram\n BOOK {\n int identifier PK,FK,UK\n varchar title \"display name\"\n decimal(10,2) price\n }\n BOOK ||--o{ PAGE : contains\n PAGE {\n int identifier PK\n int book_id FK\n }";
328 let svg = diagram_svg(source, false).expect("complete attribute table");
329 let xml = roxmltree::Document::parse(&svg).unwrap();
330 let text: Vec<_> = xml
331 .descendants()
332 .filter(|n| n.is_text())
333 .filter_map(|n| n.text())
334 .collect();
335 for field in [
336 "int",
337 "varchar",
338 "decimal(10,2)",
339 "PK",
340 "FK",
341 "UK",
342 "identifier",
343 "price",
344 "book_id",
345 ] {
346 assert!(text.contains(&field), "Missing: {field}");
347 }
348 assert!(text.iter().any(|s| s.contains("display name")));
349 let graph = mermaid_rs_renderer::parse_mermaid_strict(source)
351 .unwrap()
352 .graph;
353 assert!(!er_content_is_complete(
354 &graph,
355 &svg.replace(">varchar<", "><")
356 ));
357 }
358
359 #[test]
360 fn additional_diagram_collections_and_numbers_are_bounded() {
361 for source in [
362 format!(
363 "pie\n{}",
364 (0..65)
365 .map(|n| format!("\"Item {n}\": 1\n"))
366 .collect::<String>()
367 ),
368 format!(
369 "timeline\n{}",
370 (0..65)
371 .map(|n| format!("{n} : Event\n"))
372 .collect::<String>()
373 ),
374 format!(
375 "quadrantChart\n{}",
376 (0..129)
377 .map(|n| format!("P{n}: [0.2, 0.5]\n"))
378 .collect::<String>()
379 ),
380 format!(
381 "mindmap\n{}",
382 (0..34)
383 .map(|n| format!("{}N{n}\n", " ".repeat(n)))
384 .collect::<String>()
385 ),
386 "pie\n\"Empty\": 0".into(),
387 "quadrantChart\nOut: [2, 0]".into(),
388 "timeline\nsection Hidden\n2026 : Event".into(),
389 "journey\nTask: 6: Reader".into(),
390 "journey\nTask: NaN: Reader".into(),
391 "pie\n\"Infinite\": 1e39".into(),
392 ] {
393 assert!(render(&source, false).is_none(), "Accepted: {source}");
394 }
395 }
396
397 #[test]
398 fn unsupported_excessive_or_external_content_keeps_source() {
399 for source in [
400 "gantt\nsection Plan\nTask: 2025-01-01, 1d\n",
401 "%%{init: {}}%%\nflowchart LR\nA-->B\n",
402 "---\ntheme: dark\n---\nflowchart LR; A-->B",
403 "flowchart LR\nA\x1b[2J-->B",
404 "flowchart LR\nA[中文]-->B",
405 "flowchart LR\nA[Broken",
406 "flowchart LR\nA-->B\nclick A \"https://example.com\"\n",
407 ] {
408 assert!(render(source, false).is_none(), "Accepted: {source}");
409 }
410 assert!(!admissible(&format!(
411 "flowchart LR\n{}",
412 "A".repeat(MAX_SOURCE)
413 )));
414 assert!(!admissible(&format!("flowchart LR\n{}", "A;".repeat(257))));
415 assert!(!admissible(&format!(
416 "flowchart LR\nA{}B{}",
417 "[".repeat(33),
418 "]".repeat(33)
419 )));
420 let large = format!(
421 "flowchart LR\n{}",
422 (0..129)
423 .map(|i| format!("N{i}[Node {i}]\n"))
424 .collect::<String>()
425 );
426 assert!(render(&large, false).is_none());
427 assert!(render("flowchart LR\nA-->B", false).is_some());
428 }
429}