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/// Canvas treatment, independent of light/dark diagram colors and raster fitting.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum Background {
12    /// Paint the theme's canvas color (the default for existing callers).
13    #[default]
14    Theme,
15    /// Leave the canvas unpainted so the host surface shows through.
16    Transparent,
17}
18
19/// Reject excessive input and configuration before starting a helper or parser.
20pub 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    // A conservative lexical nesting guard bounds work before native parsing.
58    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        // Attribute rows are records, not paragraphs: wrapping can change their
185        // meaning when the backend splits type/name/key fields during drawing.
186        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        // ER table bodies also use the canvas color. Keep their resolved fills
192        // explicit, including class/style overrides, before clearing the canvas.
193        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
213// The backend may hide a type column if it cannot fit. Never display an ER
214// table that silently drops a field or key: count the parsed record tokens in
215// the generated text, including repeated types and quoted comments.
216fn 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                // A node may deliberately use the canvas color. It must remain
349                // opaque; transparency cannot be implemented as a color key.
350                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                // Compositing over the old canvas recovers the original edges,
360                // including ER table bodies and the timeline's nested background.
361                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        // A deliberately incomplete renderer result must never be accepted.
445        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        // Newlines and semicolons are optional between chained edges. The parsed
524        // graph must enforce the edge budget too, before attempting layout.
525        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}