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
9extern crate self as web_time;
10
11#[cfg(feature = "cytoscape-layout")]
12pub mod architecture;
13#[cfg(feature = "cytoscape-layout")]
14pub(crate) mod architecture_metrics;
15pub mod block;
16pub mod c4;
17mod chart_palette;
18pub mod class;
19mod config;
20mod entities;
21pub mod er;
22pub mod error;
23pub mod eventmodeling;
24pub mod flowchart;
25pub mod gantt;
26mod generated;
27pub mod gitgraph;
28mod host_time;
29pub mod info;
30pub mod ishikawa;
31pub mod journey;
32mod json;
33pub mod kanban;
34pub mod math;
35mod mermaid_style;
36#[cfg(feature = "cytoscape-layout")]
37pub mod mindmap;
38pub mod model;
39pub mod packet;
40pub mod pie;
41pub mod quadrantchart;
42pub mod radar;
43pub mod requirement;
44pub mod resources;
45pub mod sankey;
46pub mod sequence;
47pub mod state;
48pub mod svg;
49pub mod text;
50mod theme;
51pub mod timeline;
52pub mod tree_view;
53pub mod treemap;
54mod trig_tables;
55pub mod venn;
56pub mod xychart;
57
58pub(crate) use host_time::{Duration, Instant};
59
60use crate::math::MathRenderer;
61use crate::model::{LayoutDiagram, LayoutMeta, LayoutedDiagram};
62use crate::text::{DeterministicTextMeasurer, TextMeasurer};
63use merman_core::diagrams::flowchart::FlowchartV2Model;
64use merman_core::models::class_diagram::ClassDiagram;
65use merman_core::{ParsedDiagram, ParsedDiagramRender, RenderSemanticModel};
66use serde_json::Value;
67use std::sync::Arc;
68
69pub use resources::{
70    ClassComplexity, FlowchartComplexity, RenderResourceLimits, RenderResourceProfile,
71    ResourceLimitExceeded, ResourceLimitPhase,
72};
73
74#[derive(Debug, thiserror::Error)]
75pub enum Error {
76    #[error("unsupported diagram type for layout: {diagram_type}")]
77    UnsupportedDiagram { diagram_type: String },
78    #[error("invalid semantic model: {message}")]
79    InvalidModel { message: String },
80    #[error("SVG postprocessor `{pass}` failed: {message}")]
81    SvgPostprocess { pass: String, message: String },
82    #[error(transparent)]
83    ResourceLimitExceeded(#[from] ResourceLimitExceeded),
84    #[error("semantic model JSON error: {0}")]
85    Json(#[from] serde_json::Error),
86}
87
88pub type Result<T> = std::result::Result<T, Error>;
89
90impl Error {
91    pub fn svg_postprocess(pass: impl Into<String>, message: impl Into<String>) -> Self {
92        Self::SvgPostprocess {
93            pass: pass.into(),
94            message: message.into(),
95        }
96    }
97}
98
99#[derive(Clone)]
100pub struct LayoutOptions {
101    pub text_measurer: Arc<dyn TextMeasurer + Send + Sync>,
102    /// Optional math renderer for `$$...$$` style labels.
103    pub math_renderer: Option<Arc<dyn MathRenderer + Send + Sync>>,
104    pub viewport_width: f64,
105    pub viewport_height: f64,
106    /// Enable experimental layout engines (e.g. Cytoscape COSE/FCoSE ports) for diagrams that
107    /// currently use placeholder layouts in merman.
108    pub use_manatee_layout: bool,
109    /// Selects the Flowchart ELK backend.
110    ///
111    /// `SourcePorted` executes the Rust source port of ELK layered layout. `Compat` keeps the
112    /// previous lightweight backend available as an explicit alpha fallback.
113    pub flowchart_elk_backend: FlowchartElkBackend,
114    /// Resource budget applied during layout-heavy model processing.
115    pub resource_limits: RenderResourceLimits,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub enum FlowchartElkBackend {
120    Compat,
121    #[default]
122    SourcePorted,
123}
124
125impl Default for LayoutOptions {
126    fn default() -> Self {
127        Self {
128            text_measurer: Arc::new(DeterministicTextMeasurer::default()),
129            math_renderer: None,
130            viewport_width: 800.0,
131            viewport_height: 600.0,
132            use_manatee_layout: false,
133            flowchart_elk_backend: FlowchartElkBackend::SourcePorted,
134            resource_limits: RenderResourceLimits::interactive(),
135        }
136    }
137}
138
139impl LayoutOptions {
140    /// Returns layout defaults suitable for headless SVG rendering in UI integrations.
141    ///
142    /// Compared to `Default`, this uses a Mermaid-like text measurer backed by vendored font
143    /// metrics (instead of deterministic placeholder metrics). The vendored measurer is
144    /// intentionally lightweight and fixture-oriented; hosts that need exact platform font behavior
145    /// should provide their own [`TextMeasurer`] with [`LayoutOptions::with_text_measurer`].
146    pub fn headless_svg_defaults() -> Self {
147        Self {
148            text_measurer: Arc::new(crate::text::VendoredFontMetricsTextMeasurer::default()),
149            // Mermaid parity fixtures for diagrams like mindmap/architecture rely on the COSE
150            // layout port (manatee). Make the headless defaults "just work" for UI integrations.
151            use_manatee_layout: true,
152            ..Default::default()
153        }
154    }
155
156    pub fn with_text_measurer(mut self, measurer: Arc<dyn TextMeasurer + Send + Sync>) -> Self {
157        self.text_measurer = measurer;
158        self
159    }
160
161    pub fn with_math_renderer(mut self, renderer: Arc<dyn MathRenderer + Send + Sync>) -> Self {
162        self.math_renderer = Some(renderer);
163        self
164    }
165
166    pub fn with_resource_limits(mut self, limits: RenderResourceLimits) -> Self {
167        self.resource_limits = limits;
168        self
169    }
170}
171
172pub fn layout_parsed(parsed: &ParsedDiagram, options: &LayoutOptions) -> Result<LayoutedDiagram> {
173    let meta = LayoutMeta::from_parse_metadata(&parsed.meta);
174    let layout = layout_parsed_layout_only(parsed, options)?;
175
176    Ok(LayoutedDiagram {
177        meta,
178        semantic: crate::json::clone_value_nonrecursive(&parsed.model),
179        layout,
180    })
181}
182
183pub fn layout_parsed_layout_only(
184    parsed: &ParsedDiagram,
185    options: &LayoutOptions,
186) -> Result<LayoutDiagram> {
187    let diagram_type = parsed.meta.diagram_type.as_str();
188    let title = parsed.meta.title.as_deref();
189    layout_json_by_type(
190        diagram_type,
191        &parsed.model,
192        &parsed.meta.effective_config,
193        title,
194        options,
195    )
196}
197
198pub fn layout_parsed_render_layout_only(
199    parsed: &ParsedDiagramRender,
200    options: &LayoutOptions,
201) -> Result<LayoutDiagram> {
202    let diagram_type = parsed.meta.diagram_type.as_str();
203    let effective_config = parsed.meta.effective_config.as_value();
204    let title = parsed.meta.title.as_deref();
205
206    if !parsed.model.supports_diagram_type(diagram_type) {
207        return Err(Error::InvalidModel {
208            message: format!(
209                "unexpected render model variant {} for diagram type: {diagram_type}",
210                parsed.model.kind()
211            ),
212        });
213    }
214
215    match &parsed.model {
216        #[cfg(feature = "cytoscape-layout")]
217        RenderSemanticModel::Mindmap(model) => Ok(LayoutDiagram::MindmapDiagram(Box::new(
218            mindmap::layout_mindmap_diagram_typed(
219                model,
220                effective_config,
221                options.text_measurer.as_ref(),
222                options.use_manatee_layout,
223            )?,
224        ))),
225        #[cfg(not(feature = "cytoscape-layout"))]
226        RenderSemanticModel::Mindmap(_) => Err(Error::UnsupportedDiagram {
227            diagram_type: diagram_type.to_string(),
228        }),
229        #[cfg(feature = "cytoscape-layout")]
230        RenderSemanticModel::Architecture(model) => Ok(LayoutDiagram::ArchitectureDiagram(
231            Box::new(architecture::layout_architecture_diagram_typed(
232                model,
233                effective_config,
234                options.text_measurer.as_ref(),
235                options.use_manatee_layout,
236            )?),
237        )),
238        #[cfg(not(feature = "cytoscape-layout"))]
239        RenderSemanticModel::Architecture(_) => Err(Error::UnsupportedDiagram {
240            diagram_type: diagram_type.to_string(),
241        }),
242        RenderSemanticModel::Flowchart(model) => Ok(LayoutDiagram::FlowchartV2(Box::new(
243            layout_flowchart_typed_by_engine(
244                diagram_type,
245                model,
246                &parsed.meta.effective_config,
247                options,
248            )?,
249        ))),
250        RenderSemanticModel::State(model) => Ok(LayoutDiagram::StateDiagramV2(Box::new(
251            state::layout_state_diagram_v2_typed(
252                model,
253                effective_config,
254                options.text_measurer.as_ref(),
255            )?,
256        ))),
257        RenderSemanticModel::Sequence(model) => Ok(LayoutDiagram::SequenceDiagram(Box::new(
258            sequence::layout_sequence_diagram_typed_with_title(
259                model,
260                title,
261                effective_config,
262                options.text_measurer.as_ref(),
263                options.math_renderer.as_deref(),
264            )?,
265        ))),
266        RenderSemanticModel::Class(model) => Ok(LayoutDiagram::ClassDiagramV2(Box::new(
267            layout_class_typed_by_engine(
268                diagram_type,
269                model,
270                &parsed.meta.effective_config,
271                options,
272            )?,
273        ))),
274        RenderSemanticModel::C4(model) => Ok(LayoutDiagram::C4Diagram(Box::new(
275            c4::layout_c4_diagram_typed(
276                model,
277                effective_config,
278                options.text_measurer.as_ref(),
279                options.viewport_width,
280                options.viewport_height,
281            )?,
282        ))),
283        RenderSemanticModel::Kanban(model) => Ok(LayoutDiagram::KanbanDiagram(Box::new(
284            kanban::layout_kanban_diagram_typed(
285                model,
286                effective_config,
287                options.text_measurer.as_ref(),
288            )?,
289        ))),
290        RenderSemanticModel::Gantt(model) => Ok(LayoutDiagram::GanttDiagram(Box::new(
291            gantt::layout_gantt_diagram_typed(
292                model,
293                effective_config,
294                options.text_measurer.as_ref(),
295            )?,
296        ))),
297        RenderSemanticModel::Pie(model) => Ok(LayoutDiagram::PieDiagram(Box::new(
298            pie::layout_pie_diagram_typed(model, effective_config, options.text_measurer.as_ref())?,
299        ))),
300        RenderSemanticModel::Packet(model) => Ok(LayoutDiagram::PacketDiagram(Box::new(
301            packet::layout_packet_diagram_typed(
302                model,
303                title,
304                effective_config,
305                options.text_measurer.as_ref(),
306            )?,
307        ))),
308        RenderSemanticModel::Timeline(model) => Ok(LayoutDiagram::TimelineDiagram(Box::new(
309            timeline::layout_timeline_diagram_typed(
310                model,
311                effective_config,
312                options.text_measurer.as_ref(),
313            )?,
314        ))),
315        RenderSemanticModel::Journey(model) => Ok(LayoutDiagram::JourneyDiagram(Box::new(
316            journey::layout_journey_diagram_typed(
317                model,
318                effective_config,
319                options.text_measurer.as_ref(),
320            )?,
321        ))),
322        RenderSemanticModel::Requirement(model) => Ok(LayoutDiagram::RequirementDiagram(Box::new(
323            requirement::layout_requirement_diagram_typed(
324                model,
325                effective_config,
326                options.text_measurer.as_ref(),
327            )?,
328        ))),
329        RenderSemanticModel::Sankey(model) => Ok(LayoutDiagram::SankeyDiagram(Box::new(
330            sankey::layout_sankey_diagram_typed(
331                model,
332                effective_config,
333                options.text_measurer.as_ref(),
334            )?,
335        ))),
336        RenderSemanticModel::Radar(model) => Ok(LayoutDiagram::RadarDiagram(Box::new(
337            radar::layout_radar_diagram_typed(
338                model,
339                effective_config,
340                options.text_measurer.as_ref(),
341            )?,
342        ))),
343        RenderSemanticModel::Info(model) => Ok(LayoutDiagram::InfoDiagram(Box::new(
344            info::layout_info_diagram_typed(
345                model,
346                effective_config,
347                options.text_measurer.as_ref(),
348            )?,
349        ))),
350        RenderSemanticModel::Treemap(model) => Ok(LayoutDiagram::TreemapDiagram(Box::new(
351            treemap::layout_treemap_diagram_typed(
352                model,
353                effective_config,
354                options.text_measurer.as_ref(),
355            )?,
356        ))),
357        RenderSemanticModel::Block(model) => Ok(LayoutDiagram::BlockDiagram(Box::new(
358            block::layout_block_diagram_typed(
359                model,
360                effective_config,
361                options.text_measurer.as_ref(),
362            )?,
363        ))),
364        RenderSemanticModel::Er(model) => Ok(LayoutDiagram::ErDiagram(Box::new(
365            er::layout_er_diagram_typed(model, effective_config, options.text_measurer.as_ref())?,
366        ))),
367        RenderSemanticModel::QuadrantChart(model) => Ok(LayoutDiagram::QuadrantChartDiagram(
368            Box::new(quadrantchart::layout_quadrantchart_diagram_typed(
369                model,
370                effective_config,
371                options.text_measurer.as_ref(),
372            )?),
373        )),
374        RenderSemanticModel::XyChart(model) => Ok(LayoutDiagram::XyChartDiagram(Box::new(
375            xychart::layout_xychart_diagram_typed(
376                model,
377                effective_config,
378                options.text_measurer.as_ref(),
379            )?,
380        ))),
381        RenderSemanticModel::GitGraph(model) => Ok(LayoutDiagram::GitGraphDiagram(Box::new(
382            gitgraph::layout_gitgraph_diagram_typed(
383                model,
384                effective_config,
385                options.text_measurer.as_ref(),
386            )?,
387        ))),
388        RenderSemanticModel::TreeView(model) => Ok(LayoutDiagram::TreeViewDiagram(Box::new(
389            tree_view::layout_tree_view_diagram_typed(
390                model,
391                effective_config,
392                options.text_measurer.as_ref(),
393            )?,
394        ))),
395        RenderSemanticModel::Ishikawa(model) => Ok(LayoutDiagram::IshikawaDiagram(Box::new(
396            ishikawa::layout_ishikawa_diagram_typed(
397                model,
398                effective_config,
399                options.text_measurer.as_ref(),
400            )?,
401        ))),
402        RenderSemanticModel::EventModeling(model) => Ok(LayoutDiagram::EventModelingDiagram(
403            Box::new(eventmodeling::layout_eventmodeling_diagram_typed(
404                model,
405                effective_config,
406                options.text_measurer.as_ref(),
407            )?),
408        )),
409        RenderSemanticModel::Venn(model) => Ok(LayoutDiagram::VennDiagram(Box::new(
410            venn::layout_venn_diagram_typed(model, title, effective_config)?,
411        ))),
412        RenderSemanticModel::Json(semantic) => layout_json_by_type(
413            diagram_type,
414            semantic,
415            &parsed.meta.effective_config,
416            title,
417            options,
418        ),
419    }
420}
421
422fn flowchart_uses_elk_layout(
423    diagram_type: &str,
424    effective_config: &merman_core::MermaidConfig,
425) -> bool {
426    diagram_type == "flowchart-elk" || effective_config.get_str("layout") == Some("elk")
427}
428
429fn class_uses_elk_layout(effective_config: &merman_core::MermaidConfig) -> bool {
430    effective_config.get_str("layout") == Some("elk")
431        || effective_config.get_str("class.defaultRenderer") == Some("elk")
432}
433
434fn layout_class_typed_by_engine(
435    diagram_type: &str,
436    model: &ClassDiagram,
437    effective_config: &merman_core::MermaidConfig,
438    options: &LayoutOptions,
439) -> Result<model::ClassDiagramV2Layout> {
440    if class_uses_elk_layout(effective_config) {
441        return layout_class_elk_typed_by_feature(diagram_type, model, effective_config, options);
442    }
443
444    options.resource_limits.check_class_complexity(model)?;
445    class::layout_class_diagram_v2_typed_with_config(
446        model,
447        effective_config,
448        options.text_measurer.as_ref(),
449    )
450}
451
452fn layout_class_json_by_engine(
453    diagram_type: &str,
454    semantic: &Value,
455    effective_config: &merman_core::MermaidConfig,
456    options: &LayoutOptions,
457) -> Result<model::ClassDiagramV2Layout> {
458    let model: ClassDiagram = crate::json::from_value_ref(semantic)?;
459    if class_uses_elk_layout(effective_config) {
460        return layout_class_elk_typed_by_feature(diagram_type, &model, effective_config, options);
461    }
462
463    options.resource_limits.check_class_complexity(&model)?;
464    class::layout_class_diagram_v2_typed_with_config(
465        &model,
466        effective_config,
467        options.text_measurer.as_ref(),
468    )
469}
470
471#[cfg(feature = "elk-layout")]
472fn layout_class_elk_typed_by_feature(
473    _diagram_type: &str,
474    model: &ClassDiagram,
475    effective_config: &merman_core::MermaidConfig,
476    options: &LayoutOptions,
477) -> Result<model::ClassDiagramV2Layout> {
478    options.resource_limits.check_class_complexity(model)?;
479    class::layout_class_diagram_v2_elk_typed_with_config(
480        model,
481        effective_config,
482        options.text_measurer.as_ref(),
483    )
484}
485
486#[cfg(not(feature = "elk-layout"))]
487fn layout_class_elk_typed_by_feature(
488    diagram_type: &str,
489    _model: &ClassDiagram,
490    _effective_config: &merman_core::MermaidConfig,
491    _options: &LayoutOptions,
492) -> Result<model::ClassDiagramV2Layout> {
493    Err(Error::UnsupportedDiagram {
494        diagram_type: diagram_type.to_string(),
495    })
496}
497
498fn layout_flowchart_typed_by_engine(
499    diagram_type: &str,
500    model: &FlowchartV2Model,
501    effective_config: &merman_core::MermaidConfig,
502    options: &LayoutOptions,
503) -> Result<model::FlowchartV2Layout> {
504    if flowchart_uses_elk_layout(diagram_type, effective_config) {
505        return layout_flowchart_elk_typed_by_feature(
506            diagram_type,
507            model,
508            effective_config,
509            options,
510        );
511    }
512
513    options.resource_limits.check_flowchart_complexity(model)?;
514    flowchart::layout_flowchart_v2_typed(
515        model,
516        effective_config,
517        options.text_measurer.as_ref(),
518        options.math_renderer.as_deref(),
519    )
520}
521
522#[cfg(feature = "elk-layout")]
523fn layout_flowchart_elk_typed_by_feature(
524    _diagram_type: &str,
525    model: &FlowchartV2Model,
526    effective_config: &merman_core::MermaidConfig,
527    options: &LayoutOptions,
528) -> Result<model::FlowchartV2Layout> {
529    options.resource_limits.check_flowchart_complexity(model)?;
530    flowchart::elk::layout_flowchart_elk_typed(
531        model,
532        effective_config,
533        options.text_measurer.as_ref(),
534        options.math_renderer.as_deref(),
535        options.flowchart_elk_backend,
536    )
537}
538
539#[cfg(not(feature = "elk-layout"))]
540fn layout_flowchart_elk_typed_by_feature(
541    diagram_type: &str,
542    _model: &FlowchartV2Model,
543    _effective_config: &merman_core::MermaidConfig,
544    _options: &LayoutOptions,
545) -> Result<model::FlowchartV2Layout> {
546    Err(Error::UnsupportedDiagram {
547        diagram_type: diagram_type.to_string(),
548    })
549}
550
551fn layout_flowchart_json_by_engine(
552    diagram_type: &str,
553    semantic: &Value,
554    effective_config: &merman_core::MermaidConfig,
555    options: &LayoutOptions,
556) -> Result<model::FlowchartV2Layout> {
557    if flowchart_uses_elk_layout(diagram_type, effective_config) {
558        return layout_flowchart_elk_json_by_feature(
559            diagram_type,
560            semantic,
561            effective_config,
562            options,
563        );
564    }
565
566    let model: FlowchartV2Model = crate::json::from_value_ref(semantic)?;
567    options.resource_limits.check_flowchart_complexity(&model)?;
568    flowchart::layout_flowchart_v2(
569        semantic,
570        effective_config,
571        options.text_measurer.as_ref(),
572        options.math_renderer.as_deref(),
573    )
574}
575
576#[cfg(feature = "elk-layout")]
577fn layout_flowchart_elk_json_by_feature(
578    _diagram_type: &str,
579    semantic: &Value,
580    effective_config: &merman_core::MermaidConfig,
581    options: &LayoutOptions,
582) -> Result<model::FlowchartV2Layout> {
583    let model: FlowchartV2Model = crate::json::from_value_ref(semantic)?;
584    options.resource_limits.check_flowchart_complexity(&model)?;
585    flowchart::elk::layout_flowchart_elk(
586        semantic,
587        effective_config,
588        options.text_measurer.as_ref(),
589        options.math_renderer.as_deref(),
590        options.flowchart_elk_backend,
591    )
592}
593
594#[cfg(not(feature = "elk-layout"))]
595fn layout_flowchart_elk_json_by_feature(
596    diagram_type: &str,
597    _semantic: &Value,
598    _effective_config: &merman_core::MermaidConfig,
599    _options: &LayoutOptions,
600) -> Result<model::FlowchartV2Layout> {
601    Err(Error::UnsupportedDiagram {
602        diagram_type: diagram_type.to_string(),
603    })
604}
605
606fn layout_json_by_type(
607    diagram_type: &str,
608    semantic: &Value,
609    effective_config: &merman_core::MermaidConfig,
610    title: Option<&str>,
611    options: &LayoutOptions,
612) -> Result<LayoutDiagram> {
613    let effective_config_value = effective_config.as_value();
614
615    match diagram_type {
616        "error" => Ok(LayoutDiagram::ErrorDiagram(Box::new(
617            error::layout_error_diagram(
618                semantic,
619                effective_config_value,
620                options.text_measurer.as_ref(),
621            )?,
622        ))),
623        "block" => Ok(LayoutDiagram::BlockDiagram(Box::new(
624            block::layout_block_diagram(
625                semantic,
626                effective_config_value,
627                options.text_measurer.as_ref(),
628            )?,
629        ))),
630        #[cfg(feature = "cytoscape-layout")]
631        "architecture" => Ok(LayoutDiagram::ArchitectureDiagram(Box::new(
632            architecture::layout_architecture_diagram(
633                semantic,
634                effective_config_value,
635                options.text_measurer.as_ref(),
636                options.use_manatee_layout,
637            )?,
638        ))),
639        #[cfg(not(feature = "cytoscape-layout"))]
640        "architecture" => Err(Error::UnsupportedDiagram {
641            diagram_type: diagram_type.to_string(),
642        }),
643        "requirement" => Ok(LayoutDiagram::RequirementDiagram(Box::new(
644            requirement::layout_requirement_diagram(
645                semantic,
646                effective_config_value,
647                options.text_measurer.as_ref(),
648            )?,
649        ))),
650        "radar" => Ok(LayoutDiagram::RadarDiagram(Box::new(
651            radar::layout_radar_diagram(
652                semantic,
653                effective_config_value,
654                options.text_measurer.as_ref(),
655            )?,
656        ))),
657        "treemap" => Ok(LayoutDiagram::TreemapDiagram(Box::new(
658            treemap::layout_treemap_diagram(
659                semantic,
660                effective_config_value,
661                options.text_measurer.as_ref(),
662            )?,
663        ))),
664        "venn" => Ok(LayoutDiagram::VennDiagram(Box::new(
665            venn::layout_venn_diagram(semantic, title, effective_config_value)?,
666        ))),
667        "flowchart-v2" | "flowchart-elk" => Ok(LayoutDiagram::FlowchartV2(Box::new(
668            layout_flowchart_json_by_engine(diagram_type, semantic, effective_config, options)?,
669        ))),
670        "stateDiagram" => Ok(LayoutDiagram::StateDiagramV2(Box::new(
671            state::layout_state_diagram_v2(
672                semantic,
673                effective_config_value,
674                options.text_measurer.as_ref(),
675            )?,
676        ))),
677        "classDiagram" | "class" => Ok(LayoutDiagram::ClassDiagramV2(Box::new(
678            layout_class_json_by_engine(diagram_type, semantic, effective_config, options)?,
679        ))),
680        "er" | "erDiagram" => Ok(LayoutDiagram::ErDiagram(Box::new(er::layout_er_diagram(
681            semantic,
682            effective_config_value,
683            options.text_measurer.as_ref(),
684        )?))),
685        "sequence" | "zenuml" => Ok(LayoutDiagram::SequenceDiagram(Box::new(
686            sequence::layout_sequence_diagram_with_title(
687                semantic,
688                title,
689                effective_config_value,
690                options.text_measurer.as_ref(),
691                options.math_renderer.as_deref(),
692            )?,
693        ))),
694        "info" => Ok(LayoutDiagram::InfoDiagram(Box::new(
695            info::layout_info_diagram(
696                semantic,
697                effective_config_value,
698                options.text_measurer.as_ref(),
699            )?,
700        ))),
701        "packet" => Ok(LayoutDiagram::PacketDiagram(Box::new(
702            packet::layout_packet_diagram(
703                semantic,
704                title,
705                effective_config_value,
706                options.text_measurer.as_ref(),
707            )?,
708        ))),
709        "timeline" => Ok(LayoutDiagram::TimelineDiagram(Box::new(
710            timeline::layout_timeline_diagram(
711                semantic,
712                effective_config_value,
713                options.text_measurer.as_ref(),
714            )?,
715        ))),
716        "gantt" => Ok(LayoutDiagram::GanttDiagram(Box::new(
717            gantt::layout_gantt_diagram(
718                semantic,
719                effective_config_value,
720                options.text_measurer.as_ref(),
721            )?,
722        ))),
723        "c4" => Ok(LayoutDiagram::C4Diagram(Box::new(c4::layout_c4_diagram(
724            semantic,
725            effective_config_value,
726            options.text_measurer.as_ref(),
727            options.viewport_width,
728            options.viewport_height,
729        )?))),
730        "journey" => Ok(LayoutDiagram::JourneyDiagram(Box::new(
731            journey::layout_journey_diagram(
732                semantic,
733                effective_config_value,
734                options.text_measurer.as_ref(),
735            )?,
736        ))),
737        "gitGraph" => Ok(LayoutDiagram::GitGraphDiagram(Box::new(
738            gitgraph::layout_gitgraph_diagram(
739                semantic,
740                effective_config_value,
741                options.text_measurer.as_ref(),
742            )?,
743        ))),
744        "kanban" => Ok(LayoutDiagram::KanbanDiagram(Box::new(
745            kanban::layout_kanban_diagram(
746                semantic,
747                effective_config_value,
748                options.text_measurer.as_ref(),
749            )?,
750        ))),
751        "pie" => Ok(LayoutDiagram::PieDiagram(Box::new(
752            pie::layout_pie_diagram(
753                semantic,
754                effective_config_value,
755                options.text_measurer.as_ref(),
756            )?,
757        ))),
758        "xychart" => Ok(LayoutDiagram::XyChartDiagram(Box::new(
759            xychart::layout_xychart_diagram(
760                semantic,
761                effective_config_value,
762                options.text_measurer.as_ref(),
763            )?,
764        ))),
765        "quadrantChart" => Ok(LayoutDiagram::QuadrantChartDiagram(Box::new(
766            quadrantchart::layout_quadrantchart_diagram(
767                semantic,
768                effective_config_value,
769                options.text_measurer.as_ref(),
770            )?,
771        ))),
772        #[cfg(feature = "cytoscape-layout")]
773        "mindmap" => Ok(LayoutDiagram::MindmapDiagram(Box::new(
774            mindmap::layout_mindmap_diagram(
775                semantic,
776                effective_config_value,
777                options.text_measurer.as_ref(),
778                options.use_manatee_layout,
779            )?,
780        ))),
781        #[cfg(not(feature = "cytoscape-layout"))]
782        "mindmap" => Err(Error::UnsupportedDiagram {
783            diagram_type: diagram_type.to_string(),
784        }),
785        "sankey" => Ok(LayoutDiagram::SankeyDiagram(Box::new(
786            sankey::layout_sankey_diagram(
787                semantic,
788                effective_config_value,
789                options.text_measurer.as_ref(),
790            )?,
791        ))),
792        "treeView" => Ok(LayoutDiagram::TreeViewDiagram(Box::new(
793            tree_view::layout_tree_view_diagram(
794                semantic,
795                effective_config_value,
796                options.text_measurer.as_ref(),
797            )?,
798        ))),
799        "ishikawa" => Ok(LayoutDiagram::IshikawaDiagram(Box::new(
800            ishikawa::layout_ishikawa_diagram(
801                semantic,
802                effective_config_value,
803                options.text_measurer.as_ref(),
804            )?,
805        ))),
806        "eventmodeling" => Ok(LayoutDiagram::EventModelingDiagram(Box::new(
807            eventmodeling::layout_eventmodeling_diagram(
808                semantic,
809                effective_config_value,
810                options.text_measurer.as_ref(),
811            )?,
812        ))),
813        other => Err(Error::UnsupportedDiagram {
814            diagram_type: other.to_string(),
815        }),
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use merman_core::{Engine, ParseOptions};
823
824    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
825    #[test]
826    fn render_model_dispatch_accepts_diagram_type_aliases() {
827        let parsed = Engine::new()
828            .parse_diagram_for_render_model_with_type_sync(
829                "flowchart-elk",
830                "flowchart-elk TD\nA-->B;",
831                ParseOptions::strict(),
832            )
833            .unwrap()
834            .unwrap();
835
836        let layout = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap();
837        assert!(matches!(layout, LayoutDiagram::FlowchartV2(_)));
838    }
839
840    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
841    #[test]
842    fn render_model_dispatch_uses_elk_for_flowchart_default_renderer_config() {
843        let parsed = Engine::new()
844            .parse_diagram_for_render_model_sync(
845                r#"---
846config:
847  flowchart:
848    defaultRenderer: elk
849---
850flowchart TD
851A-->B
852"#,
853                ParseOptions::strict(),
854            )
855            .unwrap()
856            .unwrap();
857
858        assert_eq!(parsed.meta.diagram_type, "flowchart-elk");
859        let layout = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap();
860        let LayoutDiagram::FlowchartV2(layout) = layout else {
861            panic!("expected flowchart layout");
862        };
863        let a = layout.nodes.iter().find(|node| node.id == "A").unwrap();
864        let b = layout.nodes.iter().find(|node| node.id == "B").unwrap();
865        assert!(b.y > a.y);
866    }
867
868    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
869    #[test]
870    fn render_model_dispatch_rejects_flowchart_over_node_resource_limit() {
871        let parsed = Engine::new()
872            .parse_diagram_for_render_model_with_type_sync(
873                "flowchart-elk",
874                "flowchart-elk TD\nA-->B;",
875                ParseOptions::strict(),
876            )
877            .unwrap()
878            .unwrap();
879        let options = LayoutOptions {
880            resource_limits: RenderResourceLimits {
881                max_flowchart_nodes: Some(1),
882                ..RenderResourceLimits::unbounded_for_trusted_input()
883            },
884            ..LayoutOptions::default()
885        };
886
887        let err = layout_parsed_render_layout_only(&parsed, &options).unwrap_err();
888
889        let Error::ResourceLimitExceeded(limit) = err else {
890            panic!("expected resource limit error");
891        };
892        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
893        assert_eq!(limit.limit, "max_flowchart_nodes");
894    }
895
896    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
897    #[test]
898    fn render_model_dispatch_uses_elk_for_class_layout_config() {
899        let parsed = Engine::new()
900            .parse_diagram_for_render_model_sync(
901                r#"---
902config:
903  layout: elk
904---
905classDiagram
906direction LR
907Animal <|-- Duck
908"#,
909                ParseOptions::strict(),
910            )
911            .unwrap()
912            .unwrap();
913
914        assert_eq!(parsed.meta.diagram_type, "classDiagram");
915        let layout = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap();
916        let LayoutDiagram::ClassDiagramV2(layout) = layout else {
917            panic!("expected class layout");
918        };
919        let animal = layout
920            .nodes
921            .iter()
922            .find(|node| node.id == "Animal")
923            .unwrap();
924        let duck = layout.nodes.iter().find(|node| node.id == "Duck").unwrap();
925        assert!(
926            duck.x > animal.x,
927            "ELK LR class layout should place Duck to the right of Animal; Animal={}, Duck={}",
928            animal.x,
929            duck.x
930        );
931    }
932
933    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
934    #[test]
935    fn render_model_dispatch_uses_elk_for_class_default_renderer_config() {
936        let parsed = Engine::new()
937            .parse_diagram_for_render_model_sync(
938                r#"---
939config:
940  class:
941    defaultRenderer: elk
942---
943classDiagram
944direction LR
945Animal <|-- Duck
946"#,
947                ParseOptions::strict(),
948            )
949            .unwrap()
950            .unwrap();
951
952        assert_eq!(parsed.meta.diagram_type, "classDiagram");
953        let layout = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap();
954        let LayoutDiagram::ClassDiagramV2(layout) = layout else {
955            panic!("expected class layout");
956        };
957        let animal = layout
958            .nodes
959            .iter()
960            .find(|node| node.id == "Animal")
961            .unwrap();
962        let duck = layout.nodes.iter().find(|node| node.id == "Duck").unwrap();
963        assert!(
964            duck.x > animal.x,
965            "ELK LR class layout should place Duck to the right of Animal; Animal={}, Duck={}",
966            animal.x,
967            duck.x
968        );
969    }
970
971    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
972    #[test]
973    fn render_model_dispatch_rejects_class_over_node_resource_limit() {
974        let parsed = Engine::new()
975            .parse_diagram_for_render_model_sync(
976                "classDiagram\nAnimal <|-- Duck",
977                ParseOptions::strict(),
978            )
979            .unwrap()
980            .unwrap();
981        let options = LayoutOptions {
982            resource_limits: RenderResourceLimits {
983                max_class_nodes: Some(1),
984                ..RenderResourceLimits::unbounded_for_trusted_input()
985            },
986            ..LayoutOptions::default()
987        };
988
989        let err = layout_parsed_render_layout_only(&parsed, &options).unwrap_err();
990
991        let Error::ResourceLimitExceeded(limit) = err else {
992            panic!("expected resource limit error");
993        };
994        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
995        assert_eq!(limit.limit, "max_class_nodes");
996    }
997
998    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
999    #[test]
1000    fn json_dispatch_rejects_flowchart_over_edge_resource_limit() {
1001        let parsed = Engine::new()
1002            .parse_diagram_sync("flowchart TD\nA-->B\nB-->C\nC-->D", ParseOptions::strict())
1003            .unwrap()
1004            .unwrap();
1005        let options = LayoutOptions {
1006            resource_limits: RenderResourceLimits {
1007                max_flowchart_edges: Some(2),
1008                ..RenderResourceLimits::unbounded_for_trusted_input()
1009            },
1010            ..LayoutOptions::default()
1011        };
1012
1013        let err = layout_parsed(&parsed, &options).unwrap_err();
1014
1015        let Error::ResourceLimitExceeded(limit) = err else {
1016            panic!("expected resource limit error");
1017        };
1018        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1019        assert_eq!(limit.limit, "max_flowchart_edges");
1020    }
1021
1022    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1023    #[test]
1024    fn json_dispatch_rejects_class_over_edge_resource_limit() {
1025        let parsed = Engine::new()
1026            .parse_diagram_sync(
1027                "classDiagram\nAnimal <|-- Duck\nDuck <|-- Mallard",
1028                ParseOptions::strict(),
1029            )
1030            .unwrap()
1031            .unwrap();
1032        let options = LayoutOptions {
1033            resource_limits: RenderResourceLimits {
1034                max_class_edges: Some(1),
1035                ..RenderResourceLimits::unbounded_for_trusted_input()
1036            },
1037            ..LayoutOptions::default()
1038        };
1039
1040        let err = layout_parsed(&parsed, &options).unwrap_err();
1041
1042        let Error::ResourceLimitExceeded(limit) = err else {
1043            panic!("expected resource limit error");
1044        };
1045        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1046        assert_eq!(limit.limit, "max_class_edges");
1047    }
1048
1049    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1050    #[test]
1051    fn render_layouted_svg_preserves_flowchart_elk_roledescription() {
1052        let parsed = Engine::new()
1053            .parse_diagram_sync("flowchart-elk TD\nA-->B;", ParseOptions::strict())
1054            .unwrap()
1055            .unwrap();
1056
1057        let layout_options = LayoutOptions::default();
1058        let layouted = layout_parsed(&parsed, &layout_options).unwrap();
1059        let svg = crate::svg::render_layouted_svg(
1060            &layouted,
1061            layout_options.text_measurer.as_ref(),
1062            &crate::svg::SvgRenderOptions {
1063                diagram_id: Some("elk-smoke".to_string()),
1064                ..Default::default()
1065            },
1066        )
1067        .unwrap();
1068
1069        assert!(svg.contains(r#"aria-roledescription="flowchart-elk""#));
1070        assert!(svg.contains("elk-smoke_flowchart-elk-pointEnd"));
1071        assert!(!svg.contains(r#"aria-roledescription="flowchart-v2""#));
1072        assert!(!svg.contains(r#"<g class="root""#));
1073
1074        let marker_pos = svg
1075            .find(r#"<g><marker id="elk-smoke_flowchart-elk-pointEnd""#)
1076            .expect("ELK marker group");
1077        let defs_pos = svg
1078            .find(r#"<defs><filter id="elk-smoke-drop-shadow""#)
1079            .expect("ELK shadow defs");
1080        let subgraphs_pos = svg
1081            .find(r#"<g class="subgraphs"/>"#)
1082            .expect("ELK subgraphs group");
1083        let nodes_pos = svg.find(r#"<g class="nodes">"#).expect("ELK nodes group");
1084        let edges_pos = svg
1085            .find(r#"<g class="edges edgePaths">"#)
1086            .expect("ELK edge paths group");
1087        let labels_pos = svg
1088            .find(r#"<g class="edgeLabels">"#)
1089            .expect("ELK edge labels group");
1090
1091        assert!(marker_pos < defs_pos);
1092        assert!(defs_pos < subgraphs_pos);
1093        assert!(subgraphs_pos < nodes_pos);
1094        assert!(nodes_pos < edges_pos);
1095        assert!(edges_pos < labels_pos);
1096    }
1097
1098    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1099    #[test]
1100    fn render_layouted_svg_uses_elk_adapter_dom_for_flowchart_layout_elk() {
1101        let parsed = Engine::new()
1102            .parse_diagram_sync(
1103                r#"---
1104config:
1105  layout: elk
1106---
1107flowchart LR
1108A{A} --> B & C
1109"#,
1110                ParseOptions::strict(),
1111            )
1112            .unwrap()
1113            .unwrap();
1114
1115        let layout_options = LayoutOptions {
1116            text_measurer: Arc::new(crate::text::VendoredFontMetricsTextMeasurer::default()),
1117            flowchart_elk_backend: FlowchartElkBackend::SourcePorted,
1118            ..Default::default()
1119        };
1120        let layouted = layout_parsed(&parsed, &layout_options).unwrap();
1121        let svg = crate::svg::render_layouted_svg(
1122            &layouted,
1123            layout_options.text_measurer.as_ref(),
1124            &crate::svg::SvgRenderOptions {
1125                diagram_id: Some("elk-layout-smoke".to_string()),
1126                ..Default::default()
1127            },
1128        )
1129        .unwrap();
1130
1131        assert!(svg.contains(r#"aria-roledescription="flowchart-v2""#));
1132        assert!(svg.contains("elk-layout-smoke_flowchart-v2-pointEnd"));
1133        assert!(!svg.contains(r#"<g class="root""#));
1134
1135        let marker_pos = svg
1136            .find(r#"<g><marker id="elk-layout-smoke_flowchart-v2-pointEnd""#)
1137            .expect("ELK marker group");
1138        let defs_pos = svg
1139            .find(r#"<defs><filter id="elk-layout-smoke-drop-shadow""#)
1140            .expect("ELK shadow defs");
1141        let subgraphs_pos = svg
1142            .find(r#"<g class="subgraphs"/>"#)
1143            .expect("ELK subgraphs group");
1144        let nodes_pos = svg.find(r#"<g class="nodes">"#).expect("ELK nodes group");
1145        let edges_pos = svg
1146            .find(r#"<g class="edges edgePaths">"#)
1147            .expect("ELK edge paths group");
1148        let labels_pos = svg
1149            .find(r#"<g class="edgeLabels">"#)
1150            .expect("ELK edge labels group");
1151
1152        assert!(marker_pos < defs_pos);
1153        assert!(defs_pos < subgraphs_pos);
1154        assert!(subgraphs_pos < nodes_pos);
1155        assert!(nodes_pos < edges_pos);
1156        assert!(edges_pos < labels_pos);
1157    }
1158
1159    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1160    #[test]
1161    fn render_layouted_svg_uses_right_angle_edges_for_flowchart_elk() {
1162        let parsed = Engine::new()
1163            .parse_diagram_sync("flowchart-elk LR\nA --> B\nA --> C", ParseOptions::strict())
1164            .unwrap()
1165            .unwrap();
1166
1167        let layout_options = LayoutOptions {
1168            text_measurer: Arc::new(crate::text::VendoredFontMetricsTextMeasurer::default()),
1169            ..Default::default()
1170        };
1171        let layouted = layout_parsed(&parsed, &layout_options).unwrap();
1172        let svg = crate::svg::render_layouted_svg(
1173            &layouted,
1174            layout_options.text_measurer.as_ref(),
1175            &crate::svg::SvgRenderOptions::default(),
1176        )
1177        .unwrap();
1178
1179        let path = edge_path_chunk(&svg, "L_A_B_0");
1180        let d = edge_path_d(path);
1181        assert!(
1182            d.contains('L') && !d.contains('C'),
1183            "expected ELK edges to use right-angle paths without smooth curves by default: {d}"
1184        );
1185    }
1186
1187    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1188    #[test]
1189    fn render_layouted_svg_keeps_source_ported_elk_rect_edge_boundary_points() {
1190        let parsed = Engine::new()
1191            .parse_diagram_sync(
1192                r#"---
1193config:
1194  htmlLabels: true
1195  flowchart:
1196    htmlLabels: true
1197  securityLevel: loose
1198---
1199flowchart-elk LR
1200id1(Start)-->id2(Stop)
1201"#,
1202                ParseOptions::strict(),
1203            )
1204            .unwrap()
1205            .unwrap();
1206
1207        let layout_options = LayoutOptions {
1208            text_measurer: Arc::new(crate::text::VendoredFontMetricsTextMeasurer::default()),
1209            flowchart_elk_backend: FlowchartElkBackend::SourcePorted,
1210            ..Default::default()
1211        };
1212        let layouted = layout_parsed(&parsed, &layout_options).unwrap();
1213        let svg = crate::svg::render_layouted_svg(
1214            &layouted,
1215            layout_options.text_measurer.as_ref(),
1216            &crate::svg::SvgRenderOptions::default(),
1217        )
1218        .unwrap();
1219
1220        let path = edge_path_chunk(&svg, "L_id1_id2_0");
1221        let d = edge_path_d(path);
1222        assert!(
1223            !d.contains('Q'),
1224            "straight ELK roundedRect edge should not gain a rounded corner: {d}"
1225        );
1226        let points = edge_data_points(path);
1227        assert_eq!(
1228            points.len(),
1229            2,
1230            "unexpected ELK edge data-points: {points:?}"
1231        );
1232        assert_eq!(points[0], (77.015625, 39.0));
1233        assert_eq!(points[1], (117.015625, 39.0));
1234    }
1235
1236    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1237    #[test]
1238    fn render_layouted_svg_keeps_source_ported_elk_self_loop_edges() {
1239        let parsed = Engine::new()
1240            .parse_diagram_sync("flowchart-elk TD\nA --> A", ParseOptions::strict())
1241            .unwrap()
1242            .unwrap();
1243
1244        let layout_options = LayoutOptions {
1245            text_measurer: Arc::new(crate::text::VendoredFontMetricsTextMeasurer::default()),
1246            flowchart_elk_backend: FlowchartElkBackend::SourcePorted,
1247            ..Default::default()
1248        };
1249        let layouted = layout_parsed(&parsed, &layout_options).unwrap();
1250        let svg = crate::svg::render_layouted_svg(
1251            &layouted,
1252            layout_options.text_measurer.as_ref(),
1253            &crate::svg::SvgRenderOptions::default(),
1254        )
1255        .unwrap();
1256
1257        let path = edge_path_chunk(&svg, "L_A_A_0");
1258        let d = edge_path_d(path);
1259        assert!(
1260            d.contains('Q'),
1261            "ELK self-loop path should be rendered from the source-backed edge: {d}"
1262        );
1263        let points = edge_data_points(path);
1264        assert_eq!(
1265            points.len(),
1266            4,
1267            "unexpected ELK self-loop data-points: {points:?}"
1268        );
1269        assert!(
1270            !svg.contains("A---A---1") && !svg.contains("cyclic-special"),
1271            "ELK renderer must not reuse Dagre self-loop helper nodes: {svg}"
1272        );
1273        assert!(svg.contains(r#"data-id="L_A_A_0" transform="translate(0,0)""#));
1274    }
1275
1276    #[cfg(all(feature = "core-full", not(feature = "elk-layout")))]
1277    #[test]
1278    fn render_model_dispatch_rejects_flowchart_elk_without_feature() {
1279        let parsed = Engine::new()
1280            .parse_diagram_for_render_model_with_type_sync(
1281                "flowchart-elk",
1282                "flowchart-elk TD\nA-->B;",
1283                ParseOptions::strict(),
1284            )
1285            .unwrap()
1286            .unwrap();
1287
1288        let err = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap_err();
1289        assert!(matches!(
1290            err,
1291            Error::UnsupportedDiagram { diagram_type } if diagram_type == "flowchart-elk"
1292        ));
1293    }
1294
1295    #[cfg(all(feature = "core-full", not(feature = "elk-layout")))]
1296    #[test]
1297    fn render_model_dispatch_rejects_class_elk_without_feature() {
1298        let parsed = Engine::new()
1299            .parse_diagram_for_render_model_sync(
1300                r#"---
1301config:
1302  layout: elk
1303---
1304classDiagram
1305Animal <|-- Duck
1306"#,
1307                ParseOptions::strict(),
1308            )
1309            .unwrap()
1310            .unwrap();
1311
1312        let err = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap_err();
1313        assert!(matches!(
1314            err,
1315            Error::UnsupportedDiagram { diagram_type } if diagram_type == "classDiagram"
1316        ));
1317    }
1318
1319    #[test]
1320    fn render_model_dispatch_rejects_mismatched_typed_model() {
1321        let mut parsed = Engine::new()
1322            .parse_diagram_for_render_model_sync(
1323                "sequenceDiagram\nAlice->>Bob: Hi",
1324                ParseOptions::strict(),
1325            )
1326            .unwrap()
1327            .unwrap();
1328        parsed.meta.diagram_type = "flowchart-v2".to_string();
1329
1330        let err = layout_parsed_render_layout_only(&parsed, &LayoutOptions::default()).unwrap_err();
1331        let message = err.to_string();
1332        assert!(message.contains("sequence"));
1333        assert!(message.contains("flowchart-v2"));
1334    }
1335
1336    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1337    fn edge_path_chunk<'a>(svg: &'a str, edge_id: &str) -> &'a str {
1338        let id_attr = format!(r#"id="merman-{edge_id}""#);
1339        let id_start = svg.find(&id_attr).expect("edge id");
1340        let path_start = svg[..id_start].rfind("<path ").expect("edge path start");
1341        let path_end = svg[id_start..].find("/>").expect("edge path end") + id_start;
1342        &svg[path_start..path_end]
1343    }
1344
1345    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1346    fn edge_path_d(path: &str) -> &str {
1347        let d_start = path.find(r#"d=""#).expect("edge path d") + r#"d=""#.len();
1348        let d_end = path[d_start..].find('"').expect("edge path d end") + d_start;
1349        &path[d_start..d_end]
1350    }
1351
1352    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1353    fn edge_attr_value<'a>(path: &'a str, attr: &str) -> &'a str {
1354        let needle = format!(r#"{attr}=""#);
1355        let start = path.find(&needle).expect("edge attr") + needle.len();
1356        let end = path[start..].find('"').expect("edge attr end") + start;
1357        &path[start..end]
1358    }
1359
1360    #[cfg(all(feature = "core-full", feature = "elk-layout"))]
1361    fn edge_data_points(path: &str) -> Vec<(f64, f64)> {
1362        use base64::Engine as _;
1363
1364        let b64 = edge_attr_value(path, "data-points");
1365        let bytes = base64::engine::general_purpose::STANDARD
1366            .decode(b64.as_bytes())
1367            .expect("data-points base64");
1368        let json: serde_json::Value =
1369            serde_json::from_slice(&bytes).expect("data-points JSON payload");
1370        json.as_array()
1371            .expect("data-points array")
1372            .iter()
1373            .map(|point| {
1374                (
1375                    point.get("x").and_then(serde_json::Value::as_f64).unwrap(),
1376                    point.get("y").and_then(serde_json::Value::as_f64).unwrap(),
1377                )
1378            })
1379            .collect()
1380    }
1381}