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