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