Skip to main content

document_graphics/
mermaid.rs

1//! Bounded native Mermaid subset. Parsing and layout belong in the worker process.
2
3use crate::{Raster, RasterOptions, svg};
4pub const MAX_SOURCE: usize = 16 * 1024;
5
6/// Reject excessive input and configuration before starting a helper or parser.
7pub 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    // A conservative lexical nesting guard bounds work before native parsing.
41    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    // Attribute tables in this backend require OS-font metrics and can omit
80    // types when those metrics are unavailable. Preserve the full code instead.
81    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}