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