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.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    // A conservative lexical nesting guard bounds work before native parsing.
45    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        // Attribute rows are records, not paragraphs: wrapping can change their
167        // meaning when the backend splits type/name/key fields during drawing.
168        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
179// The backend may hide a type column if it cannot fit. Never display an ER
180// table that silently drops a field or key: count the parsed record tokens in
181// the generated text, including repeated types and quoted comments.
182fn 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        // A deliberately incomplete renderer result must never be accepted.
350        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}