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;
5pub const MAX_LINES: usize = 1024;
6pub const MAX_NODES: usize = 512;
7pub const MAX_EDGES: usize = 1024;
8
9/// Reject excessive input and configuration before starting a helper or parser.
10pub 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    // A conservative lexical nesting guard bounds work before native parsing.
48    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        // Attribute rows are records, not paragraphs: wrapping can change their
170        // meaning when the backend splits type/name/key fields during drawing.
171        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
182// The backend may hide a type column if it cannot fit. Never display an ER
183// table that silently drops a field or key: count the parsed record tokens in
184// the generated text, including repeated types and quoted comments.
185fn 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        // A deliberately incomplete renderer result must never be accepted.
353        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        // Newlines and semicolons are optional between chained edges. The parsed
432        // graph must enforce the edge budget too, before attempting layout.
433        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}