Skip to main content

mermaid_rs_renderer/
lib.rs

1#![allow(clippy::field_reassign_with_default)]
2#![allow(clippy::manual_strip)]
3#![allow(clippy::needless_range_loop)]
4#![allow(clippy::redundant_locals)]
5#![allow(clippy::manual_clamp)]
6#![allow(clippy::question_mark)]
7#![allow(clippy::if_same_then_else)]
8
9//! # mmdr - Fast Mermaid Diagram Renderer
10//!
11//! A pure Rust implementation of Mermaid diagram rendering, providing 100-600x
12//! faster rendering than mermaid-cli by eliminating browser dependencies.
13//!
14//! ## Quick Start
15//!
16//! ```rust
17//! use mermaid_rs_renderer::{render, render_with_options, RenderOptions};
18//!
19//! let diagram = r#"
20//! flowchart LR
21//!     A[Start] --> B{Decision}
22//!     B -->|Yes| C[OK]
23//!     B -->|No| D[Cancel]
24//! "#;
25//!
26//! // Simple one-liner
27//! let svg = render(diagram).unwrap();
28//!
29//! // With options
30//! let svg = render_with_options(diagram, RenderOptions::default()).unwrap();
31//! ```
32//!
33//! ## Pipeline Control
34//!
35//! For more control over the rendering pipeline, use the individual stages:
36//!
37//! ```rust
38//! use mermaid_rs_renderer::{parse_mermaid, compute_layout, render_svg};
39//! use mermaid_rs_renderer::{Theme, LayoutConfig};
40//!
41//! let diagram = "flowchart LR; A-->B-->C";
42//!
43//! // Stage 1: Parse
44//! let parsed = parse_mermaid(diagram).unwrap();
45//!
46//! // Stage 2: Layout
47//! let theme = Theme::modern();
48//! let config = LayoutConfig::default();
49//! let layout = compute_layout(&parsed.graph, &theme, &config);
50//!
51//! // Stage 3: Render
52//! let svg = render_svg(&layout, &theme, &config);
53//! ```
54//!
55//! ## Supported Diagram Types
56//!
57//! - **Flowcharts** (`flowchart` / `graph`): TD, TB, LR, RL, BT directions
58//! - **Sequence Diagrams** (`sequenceDiagram`)
59//! - **Class Diagrams** (`classDiagram`)
60//! - **State Diagrams** (`stateDiagram-v2`)
61//! - **ER Diagrams** (`erDiagram`)
62//! - **Pie Charts** (`pie`)
63//! - **XY Charts** (`xychart`)
64//! - **Quadrant Charts** (`quadrantChart`)
65//! - **Gantt** (`gantt`)
66//! - **Timeline** (`timeline`)
67//! - **Journey** (`journey`)
68//! - **Mindmap** (`mindmap`)
69//! - **Git Graph** (`gitGraph`)
70//!
71//! ## Features
72//!
73//! - Pure Rust, no browser or Node.js required
74//! - ~3ms cold start vs ~2500ms for mermaid-cli
75//! - ~15MB memory vs ~300MB for mermaid-cli
76//! - SVG and PNG output (PNG via resvg)
77//! - Customizable themes and layout configuration
78//!
79//! ## Cargo Features
80//!
81//! - **`cli`** (default) - CLI binary support. Disable for library-only usage.
82//! - **`png`** (default) - PNG output via resvg. Disable for SVG-only usage.
83//!
84//! For minimal dependencies (e.g., embedding in other tools like Zola):
85//!
86//! ```toml
87//! [dependencies]
88//! mermaid-rs-renderer = { version = "0.1", default-features = false }
89//! ```
90
91#[cfg(feature = "cli")]
92pub mod cli;
93pub mod config;
94pub mod ir;
95pub mod layout;
96pub mod layout_dump;
97pub mod parser;
98pub mod render;
99mod text_metrics;
100pub mod theme;
101
102// Re-export commonly used types at crate root for ergonomic library usage
103pub use config::{Config, LayoutConfig, RenderConfig};
104pub use ir::{
105    DiagramKind, Direction, Edge, EdgeArrowhead, EdgeDecoration, EdgeStyle, Graph, Node, NodeLink,
106    NodeShape, SequenceActivation, SequenceActivationKind, SequenceBox, StateNote,
107    StateNotePosition, Subgraph,
108};
109pub use layout::{
110    EdgeLayout, Layout, LayoutStageMetrics, NodeLayout, SubgraphLayout, compute_layout,
111    compute_layout_with_metrics,
112};
113pub use parser::{ParseOutput, parse_mermaid};
114#[cfg(feature = "png")]
115pub use render::write_output_png;
116pub use render::{render_svg, write_output_svg};
117pub use theme::Theme;
118
119/// Options for the high-level `render` function.
120#[derive(Debug, Clone)]
121pub struct RenderOptions {
122    /// Theme to use for colors and styling.
123    pub theme: Theme,
124    /// Layout configuration (spacing, etc.).
125    pub layout: LayoutConfig,
126}
127
128impl Default for RenderOptions {
129    fn default() -> Self {
130        Self {
131            theme: Theme::modern(),
132            layout: LayoutConfig::default(),
133        }
134    }
135}
136
137impl RenderOptions {
138    /// Create options with the modern theme (default).
139    pub fn modern() -> Self {
140        Self::default()
141    }
142
143    /// Create options with the classic Mermaid theme.
144    pub fn mermaid_default() -> Self {
145        Self {
146            theme: Theme::mermaid_default(),
147            layout: LayoutConfig::default(),
148        }
149    }
150
151    /// Set custom node spacing.
152    pub fn with_node_spacing(mut self, spacing: f32) -> Self {
153        self.layout.node_spacing = spacing;
154        self
155    }
156
157    /// Set custom rank spacing (vertical/horizontal gap between ranks).
158    pub fn with_rank_spacing(mut self, spacing: f32) -> Self {
159        self.layout.rank_spacing = spacing;
160        self
161    }
162
163    /// Hint the renderer to target a preferred output aspect ratio (`width / height`).
164    ///
165    /// Invalid values (non-finite or `<= 0`) are ignored.
166    pub fn with_preferred_aspect_ratio(mut self, ratio: f32) -> Self {
167        if ratio.is_finite() && ratio > 0.0 {
168            self.layout.preferred_aspect_ratio = Some(ratio);
169        }
170        self
171    }
172
173    /// Hint the renderer to target a preferred output aspect ratio from
174    /// explicit width and height parts (e.g. `16` and `9`).
175    pub fn with_preferred_aspect_ratio_parts(mut self, width: f32, height: f32) -> Self {
176        if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
177            self.layout.preferred_aspect_ratio = Some(width / height);
178        }
179        self
180    }
181}
182
183/// Render a Mermaid diagram to SVG with default options.
184///
185/// This is the simplest way to render a diagram. For more control,
186/// use [`render_with_options`] or the individual pipeline functions.
187///
188/// # Example
189///
190/// ```rust
191/// use mermaid_rs_renderer::render;
192///
193/// let svg = render("flowchart LR; A-->B-->C").unwrap();
194/// assert!(svg.contains("<svg"));
195/// ```
196///
197/// # Errors
198///
199/// Returns an error if the diagram syntax is invalid.
200pub fn render(input: &str) -> anyhow::Result<String> {
201    render_with_options(input, RenderOptions::default())
202}
203
204/// Render a Mermaid diagram to SVG with custom options.
205///
206/// # Example
207///
208/// ```rust
209/// use mermaid_rs_renderer::{render_with_options, RenderOptions};
210///
211/// let opts = RenderOptions::mermaid_default()
212///     .with_node_spacing(60.0)
213///     .with_rank_spacing(80.0);
214///
215/// let svg = render_with_options("flowchart LR; A-->B", opts).unwrap();
216/// ```
217pub fn render_with_options(input: &str, options: RenderOptions) -> anyhow::Result<String> {
218    let parsed = parse_mermaid(input)?;
219    let layout = compute_layout(&parsed.graph, &options.theme, &options.layout);
220    let svg = render_svg(&layout, &options.theme, &options.layout);
221    Ok(svg)
222}
223
224/// Result of rendering with timing information.
225#[derive(Debug, Clone)]
226pub struct RenderResult {
227    /// The rendered SVG string.
228    pub svg: String,
229    /// Time spent parsing (microseconds).
230    pub parse_us: u128,
231    /// Time spent computing layout (microseconds).
232    pub layout_us: u128,
233    /// Time spent rendering to SVG (microseconds).
234    pub render_us: u128,
235}
236
237impl RenderResult {
238    /// Total render time in microseconds.
239    pub fn total_us(&self) -> u128 {
240        self.parse_us + self.layout_us + self.render_us
241    }
242
243    /// Total render time in milliseconds.
244    pub fn total_ms(&self) -> f64 {
245        self.total_us() as f64 / 1000.0
246    }
247}
248
249/// Result of rendering with detailed layout stage timing information.
250#[derive(Debug, Clone)]
251pub struct RenderDetailedResult {
252    /// The rendered SVG string.
253    pub svg: String,
254    /// Time spent parsing (microseconds).
255    pub parse_us: u128,
256    /// Time spent computing layout (microseconds).
257    pub layout_us: u128,
258    /// Time spent rendering to SVG (microseconds).
259    pub render_us: u128,
260    /// Fine-grained layout stage timings (microseconds).
261    pub layout_stages: LayoutStageMetrics,
262}
263
264impl RenderDetailedResult {
265    /// Total render time in microseconds.
266    pub fn total_us(&self) -> u128 {
267        self.parse_us + self.layout_us + self.render_us
268    }
269
270    /// Total render time in milliseconds.
271    pub fn total_ms(&self) -> f64 {
272        self.total_us() as f64 / 1000.0
273    }
274}
275
276/// Render a Mermaid diagram to SVG with timing information.
277///
278/// Useful for benchmarking and profiling.
279///
280/// # Example
281///
282/// ```rust
283/// use mermaid_rs_renderer::{render_with_timing, RenderOptions};
284///
285/// let result = render_with_timing("flowchart LR; A-->B", RenderOptions::default()).unwrap();
286/// println!("Rendered in {:.2}ms", result.total_ms());
287/// println!("  Parse:  {}us", result.parse_us);
288/// println!("  Layout: {}us", result.layout_us);
289/// println!("  Render: {}us", result.render_us);
290/// ```
291pub fn render_with_timing(input: &str, options: RenderOptions) -> anyhow::Result<RenderResult> {
292    let detailed = render_with_detailed_timing(input, options)?;
293    Ok(RenderResult {
294        svg: detailed.svg,
295        parse_us: detailed.parse_us,
296        layout_us: detailed.layout_us,
297        render_us: detailed.render_us,
298    })
299}
300
301/// Render a Mermaid diagram to SVG with detailed timing information.
302///
303/// Includes a stage-level timing breakdown for layout (port assignment, edge
304/// routing, label placement) to support architecture-level profiling.
305pub fn render_with_detailed_timing(
306    input: &str,
307    options: RenderOptions,
308) -> anyhow::Result<RenderDetailedResult> {
309    use std::time::Instant;
310
311    let t0 = Instant::now();
312    let parsed = parse_mermaid(input)?;
313    let parse_us = t0.elapsed().as_micros();
314
315    let t1 = Instant::now();
316    let (layout, layout_stages) =
317        compute_layout_with_metrics(&parsed.graph, &options.theme, &options.layout);
318    let layout_us = t1.elapsed().as_micros();
319
320    let t2 = Instant::now();
321    let svg = render_svg(&layout, &options.theme, &options.layout);
322    let render_us = t2.elapsed().as_micros();
323
324    Ok(RenderDetailedResult {
325        svg,
326        parse_us,
327        layout_us,
328        render_us,
329        layout_stages,
330    })
331}
332
333// Re-export cli::run for the binary
334#[cfg(feature = "cli")]
335pub use cli::run;
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn parse_svg_attr(svg: &str, attr: &str) -> Option<f32> {
342        let marker = format!("{attr}=\"");
343        let start = svg.find(&marker)? + marker.len();
344        let end = svg[start..].find('"')? + start;
345        svg[start..end].parse::<f32>().ok()
346    }
347
348    fn parse_viewbox_ratio(svg: &str) -> Option<f32> {
349        let marker = "viewBox=\"";
350        let start = svg.find(marker)? + marker.len();
351        let end = svg[start..].find('"')? + start;
352        let parts: Vec<&str> = svg[start..end]
353            .split(|ch: char| ch.is_ascii_whitespace() || ch == ',')
354            .filter(|part| !part.is_empty())
355            .collect();
356        if parts.len() < 4 {
357            return None;
358        }
359        let width = parts[2].parse::<f32>().ok()?;
360        let height = parts[3].parse::<f32>().ok()?;
361        if width <= 0.0 || height <= 0.0 {
362            return None;
363        }
364        Some(width / height)
365    }
366
367    #[test]
368    fn test_render_simple() {
369        let svg = render("flowchart LR; A-->B").unwrap();
370        assert!(svg.contains("<svg"));
371        assert!(svg.contains("</svg>"));
372    }
373
374    #[test]
375    fn test_render_with_options() {
376        let opts = RenderOptions::modern().with_node_spacing(100.0);
377        let svg = render_with_options("flowchart TD; X-->Y", opts).unwrap();
378        assert!(svg.contains("<svg"));
379    }
380
381    #[test]
382    fn test_render_with_timing() {
383        let result =
384            render_with_timing("flowchart LR; A-->B-->C", RenderOptions::default()).unwrap();
385        assert!(result.svg.contains("<svg"));
386        assert!(result.total_us() > 0);
387    }
388
389    #[test]
390    fn test_class_diagram() {
391        let svg = render(
392            r#"classDiagram
393            Animal <|-- Duck
394            Animal: +int age
395            Duck: +swim()"#,
396        )
397        .unwrap();
398        assert!(svg.contains("<svg"));
399    }
400
401    #[test]
402    fn test_sequence_diagram() {
403        let svg = render(
404            r#"sequenceDiagram
405            Alice->>Bob: Hello
406            Bob-->>Alice: Hi"#,
407        )
408        .unwrap();
409        assert!(svg.contains("<svg"));
410    }
411
412    #[test]
413    fn test_state_diagram() {
414        let svg = render(
415            r#"stateDiagram-v2
416            [*] --> Active
417            Active --> [*]"#,
418        )
419        .unwrap();
420        assert!(svg.contains("<svg"));
421    }
422
423    #[test]
424    fn test_pie_diagram() {
425        let svg = render(
426            r#"pie showData
427            title Pets
428            "Dogs" : 10
429            Cats : 5"#,
430        )
431        .unwrap();
432        assert!(svg.contains("<svg"));
433        assert!(svg.contains("Dogs"));
434        assert!(!svg.contains("Syntax error in text"));
435    }
436
437    #[test]
438    fn test_preferred_aspect_ratio_applies_to_svg_dimensions() {
439        let opts = RenderOptions::default().with_preferred_aspect_ratio_parts(16.0, 9.0);
440        let svg = render_with_options("flowchart LR; A-->B-->C", opts).unwrap();
441        let width = parse_svg_attr(&svg, "width").expect("width");
442        let height = parse_svg_attr(&svg, "height").expect("height");
443        let ratio = width / height;
444        assert!((ratio - (16.0 / 9.0)).abs() < 0.001);
445    }
446
447    #[test]
448    fn test_preferred_aspect_ratio_rebalances_viewbox_layout() {
449        let input = "flowchart LR; A-->B-->C-->D-->E";
450        let base_svg = render(input).unwrap();
451        let base_ratio = parse_viewbox_ratio(&base_svg).expect("base viewBox ratio");
452
453        let target_ratio = 1.0;
454        let opts = RenderOptions::default().with_preferred_aspect_ratio(target_ratio);
455        let tuned_svg = render_with_options(input, opts).unwrap();
456        let tuned_ratio = parse_viewbox_ratio(&tuned_svg).expect("tuned viewBox ratio");
457
458        assert!(
459            (tuned_ratio - target_ratio).abs() + 0.01 < (base_ratio - target_ratio).abs(),
460            "expected preferred ratio to move viewBox ratio toward target (base={base_ratio:.3}, tuned={tuned_ratio:.3})"
461        );
462        assert!(
463            (tuned_ratio - target_ratio).abs() < 0.05,
464            "expected preferred ratio to closely match target for simple flowcharts (target={target_ratio:.3}, got={tuned_ratio:.3})"
465        );
466    }
467
468    #[test]
469    fn test_preferred_aspect_ratio_handles_tall_targets() {
470        let input = "flowchart LR; A-->B-->C-->D-->E";
471        let target_ratio = 9.0 / 16.0;
472        let opts = RenderOptions::default().with_preferred_aspect_ratio(target_ratio);
473        let tuned_svg = render_with_options(input, opts).unwrap();
474        let tuned_ratio = parse_viewbox_ratio(&tuned_svg).expect("tuned viewBox ratio");
475        assert!(
476            (tuned_ratio - target_ratio).abs() < 0.05,
477            "expected tall preferred ratio to be respected (target={target_ratio:.3}, got={tuned_ratio:.3})"
478        );
479    }
480}