#![allow(clippy::field_reassign_with_default)]
#![allow(clippy::manual_strip)]
#![allow(clippy::needless_range_loop)]
#![allow(clippy::redundant_locals)]
#![allow(clippy::manual_clamp)]
#![allow(clippy::question_mark)]
#![allow(clippy::if_same_then_else)]
#![allow(unused_imports)]
#![allow(dead_code)]
pub mod config;
mod edge_geometry;
pub mod error;
pub mod ir;
pub mod layout;
pub mod layout_dump;
pub mod parser;
pub mod render;
mod text_metrics;
pub mod theme;
pub(crate) mod unicode_width;
pub mod validator;
pub use config::{Config, LayoutConfig, RenderConfig};
pub use error::ParseError;
pub use ir::{
DiagramKind, Direction, Edge, EdgeArrowhead, EdgeDecoration, EdgeStyle, Graph, Node, NodeLink,
NodeShape, SequenceActivation, SequenceActivationKind, SequenceBox, StateNote,
StateNotePosition, Subgraph,
};
pub use layout::{
EdgeLayout, Layout, LayoutStageMetrics, NodeLayout, SubgraphLayout, compute_layout,
compute_layout_with_metrics,
};
pub use parser::{ParseOutput, parse_mermaid};
pub use render::{render_svg, write_output_svg};
pub use theme::Theme;
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub theme: Theme,
pub layout: LayoutConfig,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
theme: Theme::modern(),
layout: LayoutConfig::default(),
}
}
}
impl RenderOptions {
pub fn modern() -> Self {
Self::default()
}
pub fn mermaid_default() -> Self {
Self {
theme: Theme::mermaid_default(),
layout: LayoutConfig::default(),
}
}
pub fn with_node_spacing(mut self, spacing: f32) -> Self {
self.layout.node_spacing = spacing;
self
}
pub fn with_rank_spacing(mut self, spacing: f32) -> Self {
self.layout.rank_spacing = spacing;
self
}
pub fn with_preferred_aspect_ratio(mut self, ratio: f32) -> Self {
if ratio.is_finite() && ratio > 0.0 {
self.layout.preferred_aspect_ratio = Some(ratio);
}
self
}
pub fn with_preferred_aspect_ratio_parts(mut self, width: f32, height: f32) -> Self {
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
self.layout.preferred_aspect_ratio = Some(width / height);
}
self
}
}
pub fn render(input: &str) -> anyhow::Result<String> {
render_with_options(input, RenderOptions::default())
}
pub fn render_with_options(input: &str, options: RenderOptions) -> anyhow::Result<String> {
let parsed = parse_mermaid_strict(input)?;
let layout = compute_layout(&parsed.graph, &options.theme, &options.layout);
let svg = render_svg(&layout, &options.theme, &options.layout);
Ok(svg)
}
pub fn parse_mermaid_strict(input: &str) -> Result<ParseOutput, ParseError> {
validator::validate(input)?;
parse_mermaid(input).map_err(|err| ParseError::UnexpectedToken {
line: 1,
col: 1,
found: input
.lines()
.find(|line| !line.trim().is_empty())
.map(str::trim)
.unwrap_or("<empty>")
.to_string(),
expected: err.to_string(),
})
}
pub fn render_strict(input: &str, options: RenderOptions) -> Result<String, ParseError> {
let parsed = parse_mermaid_strict(input)?;
let layout = compute_layout(&parsed.graph, &options.theme, &options.layout);
Ok(render_svg(&layout, &options.theme, &options.layout))
}
#[derive(Debug, Clone)]
pub struct RenderResult {
pub svg: String,
pub parse_us: u128,
pub layout_us: u128,
pub render_us: u128,
}
impl RenderResult {
pub fn total_us(&self) -> u128 {
self.parse_us + self.layout_us + self.render_us
}
pub fn total_ms(&self) -> f64 {
self.total_us() as f64 / 1000.0
}
}
#[derive(Debug, Clone)]
pub struct RenderDetailedResult {
pub svg: String,
pub parse_us: u128,
pub layout_us: u128,
pub render_us: u128,
pub layout_stages: LayoutStageMetrics,
}
impl RenderDetailedResult {
pub fn total_us(&self) -> u128 {
self.parse_us + self.layout_us + self.render_us
}
pub fn total_ms(&self) -> f64 {
self.total_us() as f64 / 1000.0
}
}
pub fn render_with_timing(input: &str, options: RenderOptions) -> anyhow::Result<RenderResult> {
let detailed = render_with_detailed_timing(input, options)?;
Ok(RenderResult {
svg: detailed.svg,
parse_us: detailed.parse_us,
layout_us: detailed.layout_us,
render_us: detailed.render_us,
})
}
pub fn render_with_detailed_timing(
input: &str,
options: RenderOptions,
) -> anyhow::Result<RenderDetailedResult> {
use std::time::Instant;
let t0 = Instant::now();
let parsed = parse_mermaid(input)?;
let parse_us = t0.elapsed().as_micros();
let t1 = Instant::now();
let (layout, layout_stages) =
compute_layout_with_metrics(&parsed.graph, &options.theme, &options.layout);
let layout_us = t1.elapsed().as_micros();
let t2 = Instant::now();
let svg = render_svg(&layout, &options.theme, &options.layout);
let render_us = t2.elapsed().as_micros();
Ok(RenderDetailedResult {
svg,
parse_us,
layout_us,
render_us,
layout_stages,
})
}
#[cfg(all(test, feature = "mermaid_engine_internal_tests"))]
mod tests {
use super::*;
fn parse_svg_attr(svg: &str, attr: &str) -> Option<f32> {
let marker = format!("{attr}=\"");
let start = svg.find(&marker)? + marker.len();
let end = svg[start..].find('"')? + start;
svg[start..end].parse::<f32>().ok()
}
fn parse_viewbox_ratio(svg: &str) -> Option<f32> {
let marker = "viewBox=\"";
let start = svg.find(marker)? + marker.len();
let end = svg[start..].find('"')? + start;
let parts: Vec<&str> = svg[start..end]
.split(|ch: char| ch.is_ascii_whitespace() || ch == ',')
.filter(|part| !part.is_empty())
.collect();
if parts.len() < 4 {
return None;
}
let width = parts[2].parse::<f32>().ok()?;
let height = parts[3].parse::<f32>().ok()?;
if width <= 0.0 || height <= 0.0 {
return None;
}
Some(width / height)
}
#[test]
fn test_render_simple() {
let svg = render("flowchart LR; A-->B").unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_render_with_options() {
let opts = RenderOptions::modern().with_node_spacing(100.0);
let svg = render_with_options("flowchart TD; X-->Y", opts).unwrap();
assert!(svg.contains("<svg"));
}
#[test]
fn test_render_with_timing() {
let result =
render_with_timing("flowchart LR; A-->B-->C", RenderOptions::default()).unwrap();
assert!(result.svg.contains("<svg"));
assert!(result.total_us() > 0);
}
#[test]
fn test_class_diagram() {
let svg = render(
r#"classDiagram
Animal <|-- Duck
Animal: +int age
Duck: +swim()"#,
)
.unwrap();
assert!(svg.contains("<svg"));
}
#[test]
fn test_sequence_diagram() {
let svg = render(
r#"sequenceDiagram
Alice->>Bob: Hello
Bob-->>Alice: Hi"#,
)
.unwrap();
assert!(svg.contains("<svg"));
}
#[test]
fn test_state_diagram() {
let svg = render(
r#"stateDiagram-v2
[*] --> Active
Active --> [*]"#,
)
.unwrap();
assert!(svg.contains("<svg"));
}
#[test]
fn test_pie_diagram() {
let svg = render(
r#"pie showData
title Pets
"Dogs" : 10
Cats : 5"#,
)
.unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("Dogs"));
assert!(!svg.contains("Syntax error in text"));
}
#[test]
fn test_preferred_aspect_ratio_applies_to_svg_dimensions() {
let opts = RenderOptions::default().with_preferred_aspect_ratio_parts(16.0, 9.0);
let svg = render_with_options("flowchart LR; A-->B-->C", opts).unwrap();
let width = parse_svg_attr(&svg, "width").expect("width");
let height = parse_svg_attr(&svg, "height").expect("height");
let ratio = width / height;
assert!((ratio - (16.0 / 9.0)).abs() < 0.001);
}
#[test]
fn test_preferred_aspect_ratio_rebalances_viewbox_layout() {
let input = "flowchart LR; A-->B-->C-->D-->E";
let base_svg = render(input).unwrap();
let base_ratio = parse_viewbox_ratio(&base_svg).expect("base viewBox ratio");
let target_ratio = 1.0;
let opts = RenderOptions::default().with_preferred_aspect_ratio(target_ratio);
let tuned_svg = render_with_options(input, opts).unwrap();
let tuned_ratio = parse_viewbox_ratio(&tuned_svg).expect("tuned viewBox ratio");
assert!(
(tuned_ratio - target_ratio).abs() + 0.01 < (base_ratio - target_ratio).abs(),
"expected preferred ratio to move viewBox ratio toward target (base={base_ratio:.3}, tuned={tuned_ratio:.3})"
);
assert!(
(tuned_ratio - target_ratio).abs() < 0.05,
"expected preferred ratio to closely match target for simple flowcharts (target={target_ratio:.3}, got={tuned_ratio:.3})"
);
}
#[test]
fn test_preferred_aspect_ratio_handles_tall_targets() {
let input = "flowchart LR; A-->B-->C-->D-->E";
let target_ratio = 9.0 / 16.0;
let opts = RenderOptions::default().with_preferred_aspect_ratio(target_ratio);
let tuned_svg = render_with_options(input, opts).unwrap();
let tuned_ratio = parse_viewbox_ratio(&tuned_svg).expect("tuned viewBox ratio");
assert!(
(tuned_ratio - target_ratio).abs() < 0.05,
"expected tall preferred ratio to be respected (target={target_ratio:.3}, got={tuned_ratio:.3})"
);
}
}