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            | "gantt"
55    ) {
56        return false;
57    }
58    // A conservative lexical nesting guard bounds work before native parsing.
59    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        // Attribute rows are records, not paragraphs: wrapping can change their
187        // meaning when the backend splits type/name/key fields during drawing.
188        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        // ER table bodies also use the canvas color. Keep their resolved fills
194        // explicit, including class/style overrides, before clearing the canvas.
195        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
221// The backend may hide a type column if it cannot fit. Never display an ER
222// table that silently drops a field or key: count the parsed record tokens in
223// the generated text, including repeated types and quoted comments.
224fn 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                // A node may deliberately use the canvas color. It must remain
364                // opaque; transparency cannot be implemented as a color key.
365                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                // Compositing over the old canvas recovers the original edges,
375                // including ER table bodies and the timeline's nested background.
376                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        // A deliberately incomplete renderer result must never be accepted.
536        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        // Newlines and semicolons are optional between chained edges. The parsed
615        // graph must enforce the edge budget too, before attempting layout.
616        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}