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 | "gantt"
55 ) {
56 return false;
57 }
58 let (mut depth, mut quoted, mut escaped) = (0usize, false, false);
60 for character in source.chars() {
61 if escaped {
62 escaped = false;
63 continue;
64 }
65 if character == '\\' {
66 escaped = true;
67 continue;
68 }
69 if character == '"' {
70 quoted = !quoted;
71 continue;
72 }
73 if quoted {
74 continue;
75 }
76 if matches!(character, '[' | '(' | '{') {
77 depth += 1;
78 if depth > 32 {
79 return false;
80 }
81 } else if matches!(character, ']' | ')' | '}') {
82 depth = depth.saturating_sub(1);
83 }
84 }
85 !matches!(header, "flowchart" | "graph") || (depth == 0 && !quoted)
86}
87
88pub(crate) fn render(
89 source: &str,
90 light: bool,
91 background: Background,
92 options: RasterOptions,
93) -> Option<Raster> {
94 let source = diagram_svg(source, light, background)?;
95 svg::diagram(&source, options).ok()
96}
97
98fn diagram_svg(source: &str, light: bool, background: Background) -> Option<String> {
99 use mermaid_rs_renderer::{
100 DiagramKind, LayoutConfig, Theme, compute_layout, parse_mermaid_strict, render_svg,
101 };
102 if !admissible(source) {
103 return None;
104 }
105 let parsed = parse_mermaid_strict(source).ok()?;
106 let graph = &parsed.graph;
107 if parsed.init_config.is_some()
108 || !matches!(
109 graph.kind,
110 DiagramKind::Flowchart
111 | DiagramKind::Sequence
112 | DiagramKind::Class
113 | DiagramKind::State
114 | DiagramKind::Er
115 | DiagramKind::Pie
116 | DiagramKind::Mindmap
117 | DiagramKind::Timeline
118 | DiagramKind::Journey
119 | DiagramKind::Quadrant
120 | DiagramKind::Gantt
121 )
122 || graph.nodes.len() > MAX_NODES
123 || graph.edges.len() > MAX_EDGES
124 || graph.subgraphs.len() > 16
125 || graph.subgraphs.iter().map(|g| g.nodes.len()).sum::<usize>() > 512
126 || graph.sequence_participants.len() > 32
127 || graph.sequence_frames.len() > 16
128 || graph.sequence_notes.len() > 64
129 || graph.sequence_activations.len() > 128
130 || graph.sequence_boxes.len() > 16
131 || graph.state_notes.len() > 64
132 || !graph.node_links.is_empty()
133 || graph
134 .nodes
135 .values()
136 .any(|node| node.icon.is_some() || node.label.len() > 1024)
137 {
138 return None;
139 }
140 if !additional_structure_is_bounded(graph) {
141 return None;
142 }
143 static FONTS_READY: std::sync::Once = std::sync::Once::new();
144 FONTS_READY.call_once(|| mermaid_rs_renderer::set_font_database((**svg::fonts(true)).clone()));
145 let mut theme = if light {
146 Theme::modern()
147 } else {
148 Theme::dark()
149 };
150 if !light {
151 theme.background = "#252a33".into();
152 theme.primary_color = "#243447".into();
153 theme.primary_border_color = "#63cce3".into();
154 theme.primary_text_color = "#e3eaf2".into();
155 theme.text_color = "#e3eaf2".into();
156 theme.line_color = "#b1c3d4".into();
157 theme.edge_label_background = theme.background.clone();
158 theme.sequence_actor_fill = theme.primary_color.clone();
159 theme.sequence_actor_border = theme.primary_border_color.clone();
160 }
161 theme.font_family = if source.is_ascii() {
162 "KaTeX_SansSerif"
163 } else {
164 "Noto Sans"
165 }
166 .into();
167 theme.font_size = 16.;
168 if graph.kind == DiagramKind::Pie {
169 theme.pie_colors = [
170 "#67c9de", "#b3a1e8", "#f5bc73", "#9bd59a", "#ed9eac", "#91b8ee", "#e5cb82", "#d0a5ce",
171 "#88cebb", "#c2cb89", "#ecb399", "#9fbac8",
172 ]
173 .map(str::to_owned);
174 theme.pie_opacity = 1.;
175 theme.pie_section_text_color = "#172033".into();
176 theme.pie_title_text_color = theme.text_color.clone();
177 theme.pie_legend_text_color = theme.text_color.clone();
178 theme.pie_outer_stroke_color = theme.line_color.clone();
179 theme.pie_stroke_color = theme.background.clone();
180 }
181 let mut config = LayoutConfig {
182 fast_text_metrics: true,
183 ..Default::default()
184 };
185 if graph.kind == DiagramKind::Er && graph.nodes.values().any(|n| n.label.contains('\n')) {
186 config.max_label_width_chars = 1024;
189 config.node_padding_x = 48.;
190 }
191 let mut layout = compute_layout(graph, &theme, &config);
192 if background == Background::Transparent {
193 if graph.kind == DiagramKind::Er {
196 for node in layout
197 .nodes
198 .values_mut()
199 .filter(|n| n.label.lines.len() > 1)
200 {
201 node.style
202 .fill
203 .get_or_insert_with(|| theme.background.clone());
204 }
205 }
206 theme.background = "none".into();
207 }
208 if matches!(
209 layout.diagram,
210 mermaid_rs_renderer::layout::DiagramData::Error(_)
211 ) {
212 return None;
213 }
214 let svg = render_svg(&layout, &theme, &config);
215 if graph.kind == DiagramKind::Er && !er_content_is_complete(graph, &svg) {
216 return None;
217 }
218 Some(svg)
219}
220
221fn er_content_is_complete(graph: &mermaid_rs_renderer::Graph, svg: &str) -> bool {
225 let Ok(xml) = roxmltree::Document::parse(svg) else {
226 return false;
227 };
228 let mut actual = std::collections::HashMap::<&str, usize>::new();
229 for node in xml.descendants().filter(|n| n.is_text()) {
230 for token in node
231 .text()
232 .unwrap_or_default()
233 .split_whitespace()
234 .flat_map(|s| s.split(','))
235 .map(|s| s.trim_matches(';'))
236 {
237 *actual.entry(token).or_default() += 1;
238 }
239 }
240 for node in graph.nodes.values() {
241 for token in node
242 .label
243 .lines()
244 .filter(|s| *s != "---")
245 .flat_map(str::split_whitespace)
246 .flat_map(|s| s.split(','))
247 {
248 let token = token.trim_matches(';');
249 if token.is_empty() {
250 continue;
251 }
252 let Some(count) = actual.get_mut(token) else {
253 return false;
254 };
255 if *count == 0 {
256 return false;
257 }
258 *count -= 1;
259 }
260 }
261 true
262}
263
264fn additional_structure_is_bounded(graph: &mermaid_rs_renderer::Graph) -> bool {
265 if graph.kind == mermaid_rs_renderer::DiagramKind::Gantt
266 && (graph.gantt_tasks.len() > 128
267 || graph.gantt_title.as_ref().is_some_and(|s| s.len() > 1024)
268 || graph.gantt_sections.iter().any(|s| s.len() > 1024))
269 {
270 return false;
271 }
272 if graph.kind == mermaid_rs_renderer::DiagramKind::Journey {
273 let mut actors = std::collections::HashSet::new();
274 for node in graph.nodes.values() {
275 if !node.value.is_some_and(|score| (1. ..=5.).contains(&score)) {
276 return false;
277 }
278 actors.extend(
279 node.label
280 .lines()
281 .skip(1)
282 .flat_map(|line| line.split(','))
283 .map(str::trim),
284 );
285 }
286 if actors.len() > 32 {
287 return false;
288 }
289 }
290 let label = |s: &str| s.len() <= 1024;
291 graph.pie_slices.len() <= 64
292 && graph
293 .pie_slices
294 .iter()
295 .all(|s| label(&s.label) && s.value.is_finite() && s.value >= 0.)
296 && (graph.pie_slices.is_empty() || {
297 let sum: f32 = graph.pie_slices.iter().map(|s| s.value).sum();
298 sum.is_finite() && sum > 0.
299 })
300 && graph.mindmap.nodes.len() <= 128
301 && graph
302 .mindmap
303 .nodes
304 .iter()
305 .all(|n| n.level <= 32 && n.icon.is_none() && label(&n.label))
306 && graph.timeline.sections.is_empty()
307 && graph.timeline.events.len() <= 64
308 && graph
309 .timeline
310 .events
311 .iter()
312 .map(|e| e.events.len())
313 .sum::<usize>()
314 <= 128
315 && graph
316 .timeline
317 .events
318 .iter()
319 .all(|e| label(&e.time) && e.events.iter().all(|s| label(s)))
320 && graph.quadrant.points.len() <= 128
321 && graph
322 .quadrant
323 .points
324 .iter()
325 .all(|p| label(&p.label) && (0. ..=1.).contains(&p.x) && (0. ..=1.).contains(&p.y))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::MAX_PIXELS;
332 fn render(source: &str, light: bool) -> Option<Raster> {
333 super::render(source, light, Background::Theme, RasterOptions::scaled(2.))
334 }
335 #[test]
336 fn transparent_canvases_keep_geometry_fills_and_antialiased_edges() {
337 for light in [false, true] {
338 let color = if light { "#ffffff" } else { "#252a33" };
339 let background = if light { [255u32; 3] } else { [37, 42, 51] };
340 for source in [
341 format!("flowchart LR\n A[Keep fill]-->B[Next]\n style A fill:{color}"),
342 "erDiagram\n BOOK {\n int id PK\n string title\n }".into(),
343 "timeline\n title History\n 2020 : Launch\n 2021 : Grow".into(),
344 ] {
345 let options = RasterOptions::scaled(1.5);
346 let opaque = super::render(&source, light, Background::Theme, options).unwrap();
347 let clear =
348 super::render(&source, light, Background::Transparent, options).unwrap();
349 assert_eq!((clear.width, clear.height), (opaque.width, opaque.height));
350 assert_eq!(
351 (clear.original_width, clear.original_height),
352 (opaque.original_width, opaque.original_height)
353 );
354 assert!(clear.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 0));
355 assert!(
356 clear
357 .rgba
358 .as_chunks::<4>()
359 .0
360 .iter()
361 .any(|p| (1..255).contains(&p[3]))
362 );
363 if source.starts_with("flowchart") {
366 assert!(clear.rgba.as_chunks::<4>().0.iter().any(|p| {
367 p[3] == 255
368 && p[..3]
369 .iter()
370 .zip(background)
371 .all(|(&a, b)| u32::from(a) == b)
372 }));
373 }
374 for (a, b) in clear
377 .rgba
378 .as_chunks::<4>()
379 .0
380 .iter()
381 .zip(opaque.rgba.as_chunks::<4>().0)
382 {
383 let alpha = u32::from(a[3]);
384 for i in 0..3 {
385 let composed =
386 (u32::from(a[i]) * alpha + background[i] * (255 - alpha) + 127) / 255;
387 assert!(
388 composed.abs_diff(u32::from(b[i])) <= 2,
389 "{source}: {a:?} over {background:?} != {b:?}"
390 );
391 }
392 }
393 }
394 }
395 }
396 #[test]
397 fn supported_diagrams_have_bounded_retina_rasters_in_both_themes() {
398 for source in [
399 "flowchart LR\n A[Start] --> B{Ready?}\n B -->|Yes| C[Done]\n",
400 "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Reply\n",
401 "classDiagram\n class Note {\n +String title\n }\n Note --> Reader\n",
402 "stateDiagram-v2\n [*] --> Ready\n Ready --> [*]\n",
403 "erDiagram\n BOOK ||--o{ PAGE : contains\n",
404 "flowchart TD\n subgraph Input\n A-->B\n end\n B-->C\n",
405 ] {
406 for light in [false, true] {
407 let p = render(source, light).unwrap_or_else(|| panic!("Failed: {source}"));
408 assert!(p.width > 0 && p.height > 0);
409 assert!(p.width as usize * p.height as usize <= MAX_PIXELS);
410 assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize);
411 assert!(p.rgba.as_chunks::<4>().0.iter().any(|p| p[3] > 0));
412 assert!(p.width <= p.original_width * 2 && p.height <= p.original_height * 2);
413 }
414 }
415 }
416 #[test]
417 fn unicode_attributes_and_more_diagrams_render_without_system_fonts() {
418 for source in [
419 "flowchart LR\n A[Café déjà vu] --> B[Ελληνικά Русский]",
420 "flowchart LR\n A[Cafe\u{301}] --> B[naïve façade]",
421 "sequenceDiagram\n participant A as André\n participant B as Zoë\n A->>B: prêt ?",
422 "erDiagram\n BOOK {\n string title\n int id PK,FK,UK\n string résumé \"détail, précis\"\n }",
423 "pie showData\n title Café\n \"Thé\": 3\n \"Café\": 7",
424 "mindmap\n root((Reading))\n Notes\n Ideas\n Diagrams",
425 "timeline\n title History\n 2020 : Launch : Learn\n 2021 : Grow",
426 "journey\n title Reading\n section Start\n Open file: 5: Reader\n Read: 4: Reader, Editor",
427 "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]",
428 ] {
429 for light in [false, true] {
430 assert!(render(source, light).is_some(), "Failed: {source}");
431 }
432 }
433 }
434
435 #[test]
436 fn gantt_keeps_labels_combined_tags_and_transparent_canvases() {
437 let source = "gantt\n title Café release\n axisFormat %d %b\n excludes weekends\n section Build\n Design: done, a, 2026-01-02, 1d\n Révision: crit, active, b, after a, 2d\n Ship: crit, milestone, after b, 0d";
438 for light in [false, true] {
439 let svg = diagram_svg(source, light, Background::Transparent).unwrap();
440 for label in ["Café release", "Design", "Révision", "Ship", "Build", "Jan"] {
441 assert!(svg.contains(label), "missing {label}");
442 }
443 assert!(svg.contains("<polygon"));
444 assert!(svg.contains("#e25555"));
445 let raster = super::render(
446 source,
447 light,
448 Background::Transparent,
449 RasterOptions::fit((800, 800)),
450 )
451 .unwrap();
452 assert!(raster.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 0));
453 assert!(raster.width as usize * raster.height as usize <= MAX_PIXELS);
454 }
455 }
456
457 #[test]
458 fn gantt_limits_and_invalid_schedules_keep_source() {
459 let mut source = "gantt\nStart: 2026-01-01, 1d\n".to_string();
460 for i in 1..128 {
461 source.push_str(&format!("Task {i}: 1d\n"));
462 }
463 assert!(render(&source, false).is_some());
464 source.push_str("Extra: 1d\n");
465 assert!(render(&source, false).is_none());
466 for source in [
467 "gantt\nA: 2026-01-01, 36601d",
468 "gantt\nA: 2026-02-29, 1d",
469 "gantt\nA: a, after b, 1d\nB: b, after a, 1d",
470 "gantt\nA: after missing, 1d",
471 "gantt\nA: 2026-01-01, 30q",
472 "gantt\naxisFormat %Z\nA: 2026-01-01, 1d",
473 ] {
474 assert!(render(source, false).is_none(), "accepted {source}");
475 }
476 }
477
478 #[test]
479 fn gantt_sub_day_times_have_precise_axes_and_bounded_rasters() {
480 for source in [
481 "gantt\n dateFormat HH:mm\n axisFormat %H:%M\n A: a, 09:00, 1.5h\n B: after a, 30m",
482 "gantt\n dateFormat YYYY-MM-DDTHH:mm:ss.SSS\n axisFormat %S.%L\n A: 9999-01-01T12:00:00.000, 1ms\n B: 2ms",
483 ] {
484 for light in [false, true] {
485 let svg = diagram_svg(source, light, Background::Transparent).unwrap();
486 assert!(svg.contains(if source.contains(".SSS") {
487 "00.001"
488 } else {
489 "09:00"
490 }));
491 let raster = super::render(
492 source,
493 light,
494 Background::Transparent,
495 RasterOptions::scaled(2.),
496 )
497 .unwrap();
498 assert!(raster.width as usize * raster.height as usize <= MAX_PIXELS);
499 assert!(raster.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 0));
500 }
501 }
502 for source in [
503 "gantt\ndateFormat HH:mm\nexcludes weekends\nA: 09:00, 1h",
504 "gantt\ndateFormat HH:mm\nA: 23:00, 01:00",
505 "gantt\ndateFormat HH:mm:ss.SSS\nA: 09:00:00.000, 0.5ms",
506 ] {
507 assert!(render(source, false).is_none(), "accepted {source}");
508 }
509 }
510
511 #[test]
512 fn er_tables_preserve_types_keys_and_comments() {
513 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 }";
514 let svg = diagram_svg(source, false, Background::Theme).expect("complete attribute table");
515 let xml = roxmltree::Document::parse(&svg).unwrap();
516 let text: Vec<_> = xml
517 .descendants()
518 .filter(|n| n.is_text())
519 .filter_map(|n| n.text())
520 .collect();
521 for field in [
522 "int",
523 "varchar",
524 "decimal(10,2)",
525 "PK",
526 "FK",
527 "UK",
528 "identifier",
529 "price",
530 "book_id",
531 ] {
532 assert!(text.contains(&field), "Missing: {field}");
533 }
534 assert!(text.iter().any(|s| s.contains("display name")));
535 let graph = mermaid_rs_renderer::parse_mermaid_strict(source)
537 .unwrap()
538 .graph;
539 assert!(!er_content_is_complete(
540 &graph,
541 &svg.replace(">varchar<", "><")
542 ));
543 }
544
545 #[test]
546 fn additional_diagram_collections_and_numbers_are_bounded() {
547 for source in [
548 format!(
549 "pie\n{}",
550 (0..65)
551 .map(|n| format!("\"Item {n}\": 1\n"))
552 .collect::<String>()
553 ),
554 format!(
555 "timeline\n{}",
556 (0..65)
557 .map(|n| format!("{n} : Event\n"))
558 .collect::<String>()
559 ),
560 format!(
561 "quadrantChart\n{}",
562 (0..129)
563 .map(|n| format!("P{n}: [0.2, 0.5]\n"))
564 .collect::<String>()
565 ),
566 format!(
567 "mindmap\n{}",
568 (0..34)
569 .map(|n| format!("{}N{n}\n", " ".repeat(n)))
570 .collect::<String>()
571 ),
572 "pie\n\"Empty\": 0".into(),
573 "quadrantChart\nOut: [2, 0]".into(),
574 "timeline\nsection Hidden\n2026 : Event".into(),
575 "journey\nTask: 6: Reader".into(),
576 "journey\nTask: NaN: Reader".into(),
577 "pie\n\"Infinite\": 1e39".into(),
578 ] {
579 assert!(render(&source, false).is_none(), "Accepted: {source}");
580 }
581 }
582
583 #[test]
584 fn unsupported_excessive_or_external_content_keeps_source() {
585 for source in [
586 "kanban\n column[Plan]\n task[Task]\n",
587 "%%{init: {}}%%\nflowchart LR\nA-->B\n",
588 "---\ntheme: dark\n---\nflowchart LR; A-->B",
589 "flowchart LR\nA\x1b[2J-->B",
590 "flowchart LR\nA[中文]-->B",
591 "flowchart LR\nA[Broken",
592 "flowchart LR\nA-->B\nclick A \"https://example.com\"\n",
593 ] {
594 assert!(render(source, false).is_none(), "Accepted: {source}");
595 }
596 assert!(!admissible(&format!(
597 "flowchart LR\n{}",
598 "A".repeat(MAX_SOURCE)
599 )));
600 assert!(!admissible(&format!(
601 "flowchart LR\n{}",
602 "A;".repeat(MAX_EDGES + 1)
603 )));
604 assert!(!admissible(&format!(
605 "flowchart LR\nA{}B{}",
606 "[".repeat(33),
607 "]".repeat(33)
608 )));
609 let at_limit = format!("flowchart LR\n{}", "%% comment\n".repeat(MAX_LINES - 1));
610 assert!(admissible(&at_limit));
611 assert!(!admissible(&(at_limit + "%% one too many\n")));
612 let too_many_edges = format!("flowchart LR; {}", "A-->B;".repeat(MAX_EDGES + 1));
613 assert!(!admissible(&too_many_edges));
614 let compact = format!("flowchart LR; A{}", "-->A".repeat(MAX_EDGES + 1));
617 assert!(admissible(&compact));
618 assert!(render(&compact, false).is_none());
619 let large = format!(
620 "flowchart LR\n{}",
621 (0..=MAX_NODES)
622 .map(|i| format!("N{i}[Node {i}]\n"))
623 .collect::<String>()
624 );
625 assert!(render(&large, false).is_none());
626 assert!(render("flowchart LR\nA-->B", false).is_some());
627 }
628}