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