Skip to main content

merman_render/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Headless layout + rendering for Mermaid diagrams.
4//!
5//! This crate consumes `merman-core`'s semantic models and produces:
6//! - a layout JSON (geometry + routes)
7//! - Mermaid-like SVG output with DOM parity checks against upstream baselines
8
9pub mod architecture;
10pub(crate) mod architecture_metrics;
11pub mod block;
12pub mod c4;
13mod chart_palette;
14pub mod class;
15mod config;
16mod entities;
17pub mod er;
18pub mod error;
19pub mod eventmodeling;
20pub mod flowchart;
21pub mod gantt;
22mod generated;
23pub mod gitgraph;
24pub mod info;
25pub mod ishikawa;
26pub mod journey;
27mod json;
28pub mod kanban;
29pub mod math;
30mod mermaid_style;
31pub mod mindmap;
32pub mod model;
33pub mod packet;
34pub mod pie;
35pub mod quadrantchart;
36pub mod radar;
37pub mod requirement;
38pub mod sankey;
39pub mod sequence;
40pub mod state;
41pub mod svg;
42pub mod text;
43pub mod timeline;
44pub mod tree_view;
45pub mod treemap;
46mod trig_tables;
47pub mod xychart;
48
49use crate::math::MathRenderer;
50use crate::model::{LayoutDiagram, LayoutMeta, LayoutedDiagram};
51use crate::text::{DeterministicTextMeasurer, TextMeasurer};
52use merman_core::{ParsedDiagram, ParsedDiagramRender, RenderSemanticModel};
53use serde_json::Value;
54use std::sync::Arc;
55
56#[derive(Debug, thiserror::Error)]
57pub enum Error {
58    #[error("unsupported diagram type for layout: {diagram_type}")]
59    UnsupportedDiagram { diagram_type: String },
60    #[error("invalid semantic model: {message}")]
61    InvalidModel { message: String },
62    #[error("SVG postprocessor `{pass}` failed: {message}")]
63    SvgPostprocess { pass: String, message: String },
64    #[error("semantic model JSON error: {0}")]
65    Json(#[from] serde_json::Error),
66}
67
68pub type Result<T> = std::result::Result<T, Error>;
69
70impl Error {
71    pub fn svg_postprocess(pass: impl Into<String>, message: impl Into<String>) -> Self {
72        Self::SvgPostprocess {
73            pass: pass.into(),
74            message: message.into(),
75        }
76    }
77}
78
79#[derive(Clone)]
80pub struct LayoutOptions {
81    pub text_measurer: Arc<dyn TextMeasurer + Send + Sync>,
82    /// Optional math renderer for `$$...$$` style labels.
83    pub math_renderer: Option<Arc<dyn MathRenderer + Send + Sync>>,
84    pub viewport_width: f64,
85    pub viewport_height: f64,
86    /// Enable experimental layout engines (e.g. Cytoscape COSE/FCoSE ports) for diagrams that
87    /// currently use placeholder layouts in merman.
88    pub use_manatee_layout: bool,
89}
90
91impl Default for LayoutOptions {
92    fn default() -> Self {
93        Self {
94            text_measurer: Arc::new(DeterministicTextMeasurer::default()),
95            math_renderer: None,
96            viewport_width: 800.0,
97            viewport_height: 600.0,
98            use_manatee_layout: false,
99        }
100    }
101}
102
103impl LayoutOptions {
104    /// Returns layout defaults suitable for headless SVG rendering in UI integrations.
105    ///
106    /// Compared to `Default`, this uses a Mermaid-like text measurer backed by vendored font
107    /// metrics (instead of deterministic placeholder metrics).
108    pub fn headless_svg_defaults() -> Self {
109        Self {
110            text_measurer: Arc::new(crate::text::VendoredFontMetricsTextMeasurer::default()),
111            // Mermaid parity fixtures for diagrams like mindmap/architecture rely on the COSE
112            // layout port (manatee). Make the headless defaults "just work" for UI integrations.
113            use_manatee_layout: true,
114            ..Default::default()
115        }
116    }
117
118    pub fn with_text_measurer(mut self, measurer: Arc<dyn TextMeasurer + Send + Sync>) -> Self {
119        self.text_measurer = measurer;
120        self
121    }
122
123    pub fn with_math_renderer(mut self, renderer: Arc<dyn MathRenderer + Send + Sync>) -> Self {
124        self.math_renderer = Some(renderer);
125        self
126    }
127}
128
129pub fn layout_parsed(parsed: &ParsedDiagram, options: &LayoutOptions) -> Result<LayoutedDiagram> {
130    let meta = LayoutMeta::from_parse_metadata(&parsed.meta);
131    let layout = layout_parsed_layout_only(parsed, options)?;
132
133    Ok(LayoutedDiagram {
134        meta,
135        semantic: Value::clone(&parsed.model),
136        layout,
137    })
138}
139
140pub fn layout_parsed_layout_only(
141    parsed: &ParsedDiagram,
142    options: &LayoutOptions,
143) -> Result<LayoutDiagram> {
144    let diagram_type = parsed.meta.diagram_type.as_str();
145    let title = parsed.meta.title.as_deref();
146    layout_json_by_type(
147        diagram_type,
148        &parsed.model,
149        &parsed.meta.effective_config,
150        title,
151        options,
152    )
153}
154
155pub fn layout_parsed_render_layout_only(
156    parsed: &ParsedDiagramRender,
157    options: &LayoutOptions,
158) -> Result<LayoutDiagram> {
159    let diagram_type = parsed.meta.diagram_type.as_str();
160    let effective_config = parsed.meta.effective_config.as_value();
161    let title = parsed.meta.title.as_deref();
162
163    if !parsed.model.supports_diagram_type(diagram_type) {
164        return Err(Error::InvalidModel {
165            message: format!(
166                "unexpected render model variant {} for diagram type: {diagram_type}",
167                parsed.model.kind()
168            ),
169        });
170    }
171
172    match &parsed.model {
173        RenderSemanticModel::Mindmap(model) => Ok(LayoutDiagram::MindmapDiagram(Box::new(
174            mindmap::layout_mindmap_diagram_typed(
175                model,
176                effective_config,
177                options.text_measurer.as_ref(),
178                options.use_manatee_layout,
179            )?,
180        ))),
181        RenderSemanticModel::Architecture(model) => Ok(LayoutDiagram::ArchitectureDiagram(
182            Box::new(architecture::layout_architecture_diagram_typed(
183                model,
184                effective_config,
185                options.text_measurer.as_ref(),
186                options.use_manatee_layout,
187            )?),
188        )),
189        RenderSemanticModel::Flowchart(model) => Ok(LayoutDiagram::FlowchartV2(Box::new(
190            flowchart::layout_flowchart_v2_typed(
191                model,
192                &parsed.meta.effective_config,
193                options.text_measurer.as_ref(),
194                options.math_renderer.as_deref(),
195            )?,
196        ))),
197        RenderSemanticModel::State(model) => Ok(LayoutDiagram::StateDiagramV2(Box::new(
198            state::layout_state_diagram_v2_typed(
199                model,
200                effective_config,
201                options.text_measurer.as_ref(),
202            )?,
203        ))),
204        RenderSemanticModel::Sequence(model) => Ok(LayoutDiagram::SequenceDiagram(Box::new(
205            sequence::layout_sequence_diagram_typed_with_title(
206                model,
207                title,
208                effective_config,
209                options.text_measurer.as_ref(),
210                options.math_renderer.as_deref(),
211            )?,
212        ))),
213        RenderSemanticModel::Class(model) => Ok(LayoutDiagram::ClassDiagramV2(Box::new(
214            class::layout_class_diagram_v2_typed_with_config(
215                model,
216                &parsed.meta.effective_config,
217                options.text_measurer.as_ref(),
218            )?,
219        ))),
220        RenderSemanticModel::C4(model) => Ok(LayoutDiagram::C4Diagram(Box::new(
221            c4::layout_c4_diagram_typed(
222                model,
223                effective_config,
224                options.text_measurer.as_ref(),
225                options.viewport_width,
226                options.viewport_height,
227            )?,
228        ))),
229        RenderSemanticModel::Kanban(model) => Ok(LayoutDiagram::KanbanDiagram(Box::new(
230            kanban::layout_kanban_diagram_typed(
231                model,
232                effective_config,
233                options.text_measurer.as_ref(),
234            )?,
235        ))),
236        RenderSemanticModel::Gantt(model) => Ok(LayoutDiagram::GanttDiagram(Box::new(
237            gantt::layout_gantt_diagram_typed(
238                model,
239                effective_config,
240                options.text_measurer.as_ref(),
241            )?,
242        ))),
243        RenderSemanticModel::Pie(model) => Ok(LayoutDiagram::PieDiagram(Box::new(
244            pie::layout_pie_diagram_typed(model, effective_config, options.text_measurer.as_ref())?,
245        ))),
246        RenderSemanticModel::Packet(model) => Ok(LayoutDiagram::PacketDiagram(Box::new(
247            packet::layout_packet_diagram_typed(
248                model,
249                title,
250                effective_config,
251                options.text_measurer.as_ref(),
252            )?,
253        ))),
254        RenderSemanticModel::Timeline(model) => Ok(LayoutDiagram::TimelineDiagram(Box::new(
255            timeline::layout_timeline_diagram_typed(
256                model,
257                effective_config,
258                options.text_measurer.as_ref(),
259            )?,
260        ))),
261        RenderSemanticModel::Journey(model) => Ok(LayoutDiagram::JourneyDiagram(Box::new(
262            journey::layout_journey_diagram_typed(
263                model,
264                effective_config,
265                options.text_measurer.as_ref(),
266            )?,
267        ))),
268        RenderSemanticModel::Requirement(model) => Ok(LayoutDiagram::RequirementDiagram(Box::new(
269            requirement::layout_requirement_diagram_typed(
270                model,
271                effective_config,
272                options.text_measurer.as_ref(),
273            )?,
274        ))),
275        RenderSemanticModel::Sankey(model) => Ok(LayoutDiagram::SankeyDiagram(Box::new(
276            sankey::layout_sankey_diagram_typed(
277                model,
278                effective_config,
279                options.text_measurer.as_ref(),
280            )?,
281        ))),
282        RenderSemanticModel::Radar(model) => Ok(LayoutDiagram::RadarDiagram(Box::new(
283            radar::layout_radar_diagram_typed(
284                model,
285                effective_config,
286                options.text_measurer.as_ref(),
287            )?,
288        ))),
289        RenderSemanticModel::Info(model) => Ok(LayoutDiagram::InfoDiagram(Box::new(
290            info::layout_info_diagram_typed(
291                model,
292                effective_config,
293                options.text_measurer.as_ref(),
294            )?,
295        ))),
296        RenderSemanticModel::Treemap(model) => Ok(LayoutDiagram::TreemapDiagram(Box::new(
297            treemap::layout_treemap_diagram_typed(
298                model,
299                effective_config,
300                options.text_measurer.as_ref(),
301            )?,
302        ))),
303        RenderSemanticModel::Block(model) => Ok(LayoutDiagram::BlockDiagram(Box::new(
304            block::layout_block_diagram_typed(
305                model,
306                effective_config,
307                options.text_measurer.as_ref(),
308            )?,
309        ))),
310        RenderSemanticModel::Er(model) => Ok(LayoutDiagram::ErDiagram(Box::new(
311            er::layout_er_diagram_typed(model, effective_config, options.text_measurer.as_ref())?,
312        ))),
313        RenderSemanticModel::QuadrantChart(model) => Ok(LayoutDiagram::QuadrantChartDiagram(
314            Box::new(quadrantchart::layout_quadrantchart_diagram_typed(
315                model,
316                effective_config,
317                options.text_measurer.as_ref(),
318            )?),
319        )),
320        RenderSemanticModel::XyChart(model) => Ok(LayoutDiagram::XyChartDiagram(Box::new(
321            xychart::layout_xychart_diagram_typed(
322                model,
323                effective_config,
324                options.text_measurer.as_ref(),
325            )?,
326        ))),
327        RenderSemanticModel::GitGraph(model) => Ok(LayoutDiagram::GitGraphDiagram(Box::new(
328            gitgraph::layout_gitgraph_diagram_typed(
329                model,
330                effective_config,
331                options.text_measurer.as_ref(),
332            )?,
333        ))),
334        RenderSemanticModel::TreeView(model) => Ok(LayoutDiagram::TreeViewDiagram(Box::new(
335            tree_view::layout_tree_view_diagram_typed(
336                model,
337                effective_config,
338                options.text_measurer.as_ref(),
339            )?,
340        ))),
341        RenderSemanticModel::Ishikawa(model) => Ok(LayoutDiagram::IshikawaDiagram(Box::new(
342            ishikawa::layout_ishikawa_diagram_typed(
343                model,
344                effective_config,
345                options.text_measurer.as_ref(),
346            )?,
347        ))),
348        RenderSemanticModel::EventModeling(model) => Ok(LayoutDiagram::EventModelingDiagram(
349            Box::new(eventmodeling::layout_eventmodeling_diagram_typed(
350                model,
351                effective_config,
352                options.text_measurer.as_ref(),
353            )?),
354        )),
355        RenderSemanticModel::Json(semantic) => layout_json_by_type(
356            diagram_type,
357            semantic,
358            &parsed.meta.effective_config,
359            title,
360            options,
361        ),
362    }
363}
364
365fn layout_json_by_type(
366    diagram_type: &str,
367    semantic: &Value,
368    effective_config: &merman_core::MermaidConfig,
369    title: Option<&str>,
370    options: &LayoutOptions,
371) -> Result<LayoutDiagram> {
372    let effective_config_value = effective_config.as_value();
373
374    match diagram_type {
375        "error" => Ok(LayoutDiagram::ErrorDiagram(Box::new(
376            error::layout_error_diagram(
377                semantic,
378                effective_config_value,
379                options.text_measurer.as_ref(),
380            )?,
381        ))),
382        "block" => Ok(LayoutDiagram::BlockDiagram(Box::new(
383            block::layout_block_diagram(
384                semantic,
385                effective_config_value,
386                options.text_measurer.as_ref(),
387            )?,
388        ))),
389        "architecture" => Ok(LayoutDiagram::ArchitectureDiagram(Box::new(
390            architecture::layout_architecture_diagram(
391                semantic,
392                effective_config_value,
393                options.text_measurer.as_ref(),
394                options.use_manatee_layout,
395            )?,
396        ))),
397        "requirement" => Ok(LayoutDiagram::RequirementDiagram(Box::new(
398            requirement::layout_requirement_diagram(
399                semantic,
400                effective_config_value,
401                options.text_measurer.as_ref(),
402            )?,
403        ))),
404        "radar" => Ok(LayoutDiagram::RadarDiagram(Box::new(
405            radar::layout_radar_diagram(
406                semantic,
407                effective_config_value,
408                options.text_measurer.as_ref(),
409            )?,
410        ))),
411        "treemap" => Ok(LayoutDiagram::TreemapDiagram(Box::new(
412            treemap::layout_treemap_diagram(
413                semantic,
414                effective_config_value,
415                options.text_measurer.as_ref(),
416            )?,
417        ))),
418        "flowchart-v2" => Ok(LayoutDiagram::FlowchartV2(Box::new(
419            flowchart::layout_flowchart_v2(
420                semantic,
421                effective_config,
422                options.text_measurer.as_ref(),
423                options.math_renderer.as_deref(),
424            )?,
425        ))),
426        "stateDiagram" => Ok(LayoutDiagram::StateDiagramV2(Box::new(
427            state::layout_state_diagram_v2(
428                semantic,
429                effective_config_value,
430                options.text_measurer.as_ref(),
431            )?,
432        ))),
433        "classDiagram" | "class" => Ok(LayoutDiagram::ClassDiagramV2(Box::new(
434            class::layout_class_diagram_v2_with_config(
435                semantic,
436                effective_config,
437                options.text_measurer.as_ref(),
438            )?,
439        ))),
440        "er" | "erDiagram" => Ok(LayoutDiagram::ErDiagram(Box::new(er::layout_er_diagram(
441            semantic,
442            effective_config_value,
443            options.text_measurer.as_ref(),
444        )?))),
445        "sequence" | "zenuml" => Ok(LayoutDiagram::SequenceDiagram(Box::new(
446            sequence::layout_sequence_diagram_with_title(
447                semantic,
448                title,
449                effective_config_value,
450                options.text_measurer.as_ref(),
451                options.math_renderer.as_deref(),
452            )?,
453        ))),
454        "info" => Ok(LayoutDiagram::InfoDiagram(Box::new(
455            info::layout_info_diagram(
456                semantic,
457                effective_config_value,
458                options.text_measurer.as_ref(),
459            )?,
460        ))),
461        "packet" => Ok(LayoutDiagram::PacketDiagram(Box::new(
462            packet::layout_packet_diagram(
463                semantic,
464                title,
465                effective_config_value,
466                options.text_measurer.as_ref(),
467            )?,
468        ))),
469        "timeline" => Ok(LayoutDiagram::TimelineDiagram(Box::new(
470            timeline::layout_timeline_diagram(
471                semantic,
472                effective_config_value,
473                options.text_measurer.as_ref(),
474            )?,
475        ))),
476        "gantt" => Ok(LayoutDiagram::GanttDiagram(Box::new(
477            gantt::layout_gantt_diagram(
478                semantic,
479                effective_config_value,
480                options.text_measurer.as_ref(),
481            )?,
482        ))),
483        "c4" => Ok(LayoutDiagram::C4Diagram(Box::new(c4::layout_c4_diagram(
484            semantic,
485            effective_config_value,
486            options.text_measurer.as_ref(),
487            options.viewport_width,
488            options.viewport_height,
489        )?))),
490        "journey" => Ok(LayoutDiagram::JourneyDiagram(Box::new(
491            journey::layout_journey_diagram(
492                semantic,
493                effective_config_value,
494                options.text_measurer.as_ref(),
495            )?,
496        ))),
497        "gitGraph" => Ok(LayoutDiagram::GitGraphDiagram(Box::new(
498            gitgraph::layout_gitgraph_diagram(
499                semantic,
500                effective_config_value,
501                options.text_measurer.as_ref(),
502            )?,
503        ))),
504        "kanban" => Ok(LayoutDiagram::KanbanDiagram(Box::new(
505            kanban::layout_kanban_diagram(
506                semantic,
507                effective_config_value,
508                options.text_measurer.as_ref(),
509            )?,
510        ))),
511        "pie" => Ok(LayoutDiagram::PieDiagram(Box::new(
512            pie::layout_pie_diagram(
513                semantic,
514                effective_config_value,
515                options.text_measurer.as_ref(),
516            )?,
517        ))),
518        "xychart" => Ok(LayoutDiagram::XyChartDiagram(Box::new(
519            xychart::layout_xychart_diagram(
520                semantic,
521                effective_config_value,
522                options.text_measurer.as_ref(),
523            )?,
524        ))),
525        "quadrantChart" => Ok(LayoutDiagram::QuadrantChartDiagram(Box::new(
526            quadrantchart::layout_quadrantchart_diagram(
527                semantic,
528                effective_config_value,
529                options.text_measurer.as_ref(),
530            )?,
531        ))),
532        "mindmap" => Ok(LayoutDiagram::MindmapDiagram(Box::new(
533            mindmap::layout_mindmap_diagram(
534                semantic,
535                effective_config_value,
536                options.text_measurer.as_ref(),
537                options.use_manatee_layout,
538            )?,
539        ))),
540        "sankey" => Ok(LayoutDiagram::SankeyDiagram(Box::new(
541            sankey::layout_sankey_diagram(
542                semantic,
543                effective_config_value,
544                options.text_measurer.as_ref(),
545            )?,
546        ))),
547        "treeView" => Ok(LayoutDiagram::TreeViewDiagram(Box::new(
548            tree_view::layout_tree_view_diagram(
549                semantic,
550                effective_config_value,
551                options.text_measurer.as_ref(),
552            )?,
553        ))),
554        "ishikawa" => Ok(LayoutDiagram::IshikawaDiagram(Box::new(
555            ishikawa::layout_ishikawa_diagram(
556                semantic,
557                effective_config_value,
558                options.text_measurer.as_ref(),
559            )?,
560        ))),
561        "eventmodeling" => Ok(LayoutDiagram::EventModelingDiagram(Box::new(
562            eventmodeling::layout_eventmodeling_diagram(
563                semantic,
564                effective_config_value,
565                options.text_measurer.as_ref(),
566            )?,
567        ))),
568        other => Err(Error::UnsupportedDiagram {
569            diagram_type: other.to_string(),
570        }),
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use merman_core::{Engine, ParseOptions};
578
579    #[test]
580    fn render_model_dispatch_accepts_diagram_type_aliases() {
581        let parsed = Engine::new()
582            .parse_diagram_for_render_model_as_sync(
583                "flowchart-elk",
584                "flowchart-elk TD\nA-->B;",
585                ParseOptions::strict(),
586            )
587            .unwrap()
588            .unwrap();
589
590        let layout = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap();
591        assert!(matches!(layout, LayoutDiagram::FlowchartV2(_)));
592    }
593
594    #[test]
595    fn render_model_dispatch_rejects_mismatched_typed_model() {
596        let mut parsed = Engine::new()
597            .parse_diagram_for_render_model_sync(
598                "sequenceDiagram\nAlice->>Bob: Hi",
599                ParseOptions::strict(),
600            )
601            .unwrap()
602            .unwrap();
603        parsed.meta.diagram_type = "flowchart-v2".to_string();
604
605        let err = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap_err();
606        let message = err.to_string();
607        assert!(message.contains("sequence"));
608        assert!(message.contains("flowchart-v2"));
609    }
610}