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