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#[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
102pub 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#[derive(Debug, Clone)]
121pub struct RenderOptions {
122 pub theme: Theme,
124 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 pub fn modern() -> Self {
140 Self::default()
141 }
142
143 pub fn mermaid_default() -> Self {
145 Self {
146 theme: Theme::mermaid_default(),
147 layout: LayoutConfig::default(),
148 }
149 }
150
151 pub fn with_node_spacing(mut self, spacing: f32) -> Self {
153 self.layout.node_spacing = spacing;
154 self
155 }
156
157 pub fn with_rank_spacing(mut self, spacing: f32) -> Self {
159 self.layout.rank_spacing = spacing;
160 self
161 }
162
163 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 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
183pub fn render(input: &str) -> anyhow::Result<String> {
201 render_with_options(input, RenderOptions::default())
202}
203
204pub 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#[derive(Debug, Clone)]
226pub struct RenderResult {
227 pub svg: String,
229 pub parse_us: u128,
231 pub layout_us: u128,
233 pub render_us: u128,
235}
236
237impl RenderResult {
238 pub fn total_us(&self) -> u128 {
240 self.parse_us + self.layout_us + self.render_us
241 }
242
243 pub fn total_ms(&self) -> f64 {
245 self.total_us() as f64 / 1000.0
246 }
247}
248
249#[derive(Debug, Clone)]
251pub struct RenderDetailedResult {
252 pub svg: String,
254 pub parse_us: u128,
256 pub layout_us: u128,
258 pub render_us: u128,
260 pub layout_stages: LayoutStageMetrics,
262}
263
264impl RenderDetailedResult {
265 pub fn total_us(&self) -> u128 {
267 self.parse_us + self.layout_us + self.render_us
268 }
269
270 pub fn total_ms(&self) -> f64 {
272 self.total_us() as f64 / 1000.0
273 }
274}
275
276pub 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
301pub 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#[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}