Skip to main content

merman_render/flowchart/
layout.rs

1use crate::math::MathRenderer;
2use crate::model::{
3    FlowchartV2Layout, LayoutCluster, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint,
4};
5use crate::text::{TextMeasurer, TextStyle, WrapMode};
6use crate::{Error, Result};
7use dugong::graphlib::{Graph, GraphOptions};
8use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
9use merman_core::MermaidConfig;
10use serde_json::Value;
11use std::collections::HashMap;
12
13use super::label::compute_bounds;
14use super::node::node_layout_dimensions;
15use super::{FlowEdge, FlowSubgraph, FlowchartV2Model};
16use super::{
17    flowchart_effective_text_style_for_classes, flowchart_effective_text_style_for_node_classes,
18    flowchart_label_metrics_for_layout, flowchart_node_has_span_css_height_parity,
19};
20
21fn json_f64(v: &Value) -> Option<f64> {
22    v.as_f64()
23        .or_else(|| v.as_i64().map(|n| n as f64))
24        .or_else(|| v.as_u64().map(|n| n as f64))
25}
26
27fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
28    let mut cur = cfg;
29    for key in path {
30        cur = cur.get(*key)?;
31    }
32    json_f64(cur)
33}
34
35fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
36    let mut cur = cfg;
37    for key in path {
38        cur = cur.get(*key)?;
39    }
40    cur.as_str().map(|s| s.to_string())
41}
42
43fn rank_dir_from_flow(direction: &str) -> RankDir {
44    match direction.trim().to_uppercase().as_str() {
45        "TB" | "TD" => RankDir::TB,
46        "BT" => RankDir::BT,
47        "LR" => RankDir::LR,
48        "RL" => RankDir::RL,
49        _ => RankDir::TB,
50    }
51}
52
53fn normalize_dir(s: &str) -> String {
54    s.trim().to_uppercase()
55}
56
57fn toggled_dir(parent: &str) -> String {
58    let parent = normalize_dir(parent);
59    if parent == "TB" || parent == "TD" {
60        "LR".to_string()
61    } else {
62        "TB".to_string()
63    }
64}
65
66fn flow_dir_from_rankdir(rankdir: RankDir) -> &'static str {
67    match rankdir {
68        RankDir::TB => "TB",
69        RankDir::BT => "BT",
70        RankDir::LR => "LR",
71        RankDir::RL => "RL",
72    }
73}
74
75fn effective_cluster_dir(sg: &FlowSubgraph, parent_dir: &str, inherit_dir: bool) -> String {
76    if let Some(dir) = sg.dir.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
77        return normalize_dir(dir);
78    }
79    if inherit_dir {
80        return normalize_dir(parent_dir);
81    }
82    toggled_dir(parent_dir)
83}
84
85fn compute_effective_dir_by_id(
86    subgraphs_by_id: &HashMap<String, FlowSubgraph>,
87    g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
88    diagram_dir: &str,
89    inherit_dir: bool,
90) -> HashMap<String, String> {
91    fn compute_one(
92        id: &str,
93        subgraphs_by_id: &HashMap<String, FlowSubgraph>,
94        g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
95        diagram_dir: &str,
96        inherit_dir: bool,
97        visiting: &mut std::collections::HashSet<String>,
98        memo: &mut HashMap<String, String>,
99    ) -> String {
100        if let Some(dir) = memo.get(id) {
101            return dir.clone();
102        }
103        if !visiting.insert(id.to_string()) {
104            let dir = toggled_dir(diagram_dir);
105            memo.insert(id.to_string(), dir.clone());
106            return dir;
107        }
108
109        let parent_dir = g
110            .parent(id)
111            .and_then(|p| subgraphs_by_id.contains_key(p).then_some(p.to_string()))
112            .map(|p| {
113                compute_one(
114                    &p,
115                    subgraphs_by_id,
116                    g,
117                    diagram_dir,
118                    inherit_dir,
119                    visiting,
120                    memo,
121                )
122            })
123            .unwrap_or_else(|| normalize_dir(diagram_dir));
124
125        let dir = subgraphs_by_id
126            .get(id)
127            .map(|sg| effective_cluster_dir(sg, &parent_dir, inherit_dir))
128            .unwrap_or_else(|| toggled_dir(&parent_dir));
129
130        memo.insert(id.to_string(), dir.clone());
131        let _ = visiting.remove(id);
132        dir
133    }
134
135    let mut memo: HashMap<String, String> = HashMap::new();
136    let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
137    for id in subgraphs_by_id.keys() {
138        let _ = compute_one(
139            id,
140            subgraphs_by_id,
141            g,
142            diagram_dir,
143            inherit_dir,
144            &mut visiting,
145            &mut memo,
146        );
147    }
148    memo
149}
150
151fn dir_to_rankdir(dir: &str) -> RankDir {
152    match normalize_dir(dir).as_str() {
153        "TB" | "TD" => RankDir::TB,
154        "BT" => RankDir::BT,
155        "LR" => RankDir::LR,
156        "RL" => RankDir::RL,
157        _ => RankDir::TB,
158    }
159}
160
161fn edge_label_is_non_empty(edge: &FlowEdge) -> bool {
162    edge.label
163        .as_deref()
164        .map(|s| !s.trim().is_empty())
165        .unwrap_or(false)
166}
167
168#[cfg(feature = "flowchart_root_pack")]
169fn edge_label_leaf_id(edge: &FlowEdge) -> String {
170    format!("edge-label::{}", edge.id)
171}
172
173fn lowest_common_parent(
174    g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
175    a: &str,
176    b: &str,
177) -> Option<String> {
178    if !g.options().compound {
179        return None;
180    }
181
182    let mut ancestors: std::collections::HashSet<String> = std::collections::HashSet::new();
183    let mut cur = g.parent(a);
184    while let Some(p) = cur {
185        ancestors.insert(p.to_string());
186        cur = g.parent(p);
187    }
188
189    let mut cur = g.parent(b);
190    while let Some(p) = cur {
191        if ancestors.contains(p) {
192            return Some(p.to_string());
193        }
194        cur = g.parent(p);
195    }
196
197    None
198}
199
200fn extract_descendants(id: &str, g: &Graph<NodeLabel, EdgeLabel, GraphLabel>) -> Vec<String> {
201    let children = g.children(id);
202    let mut out: Vec<String> = children.iter().map(|s| s.to_string()).collect();
203    for child in children {
204        out.extend(extract_descendants(child, g));
205    }
206    out
207}
208
209fn edge_in_cluster(
210    edge: &dugong::graphlib::EdgeKey,
211    cluster_id: &str,
212    descendants: &std::collections::HashMap<String, Vec<String>>,
213) -> bool {
214    if edge.v == cluster_id || edge.w == cluster_id {
215        return false;
216    }
217    let Some(cluster_desc) = descendants.get(cluster_id) else {
218        return false;
219    };
220    cluster_desc.contains(&edge.v) || cluster_desc.contains(&edge.w)
221}
222
223#[derive(Debug, Clone)]
224struct FlowchartClusterDbEntry {
225    anchor_id: String,
226    external_connections: bool,
227}
228
229fn flowchart_find_common_edges(
230    graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
231    id1: &str,
232    id2: &str,
233) -> Vec<(String, String)> {
234    let edges1: Vec<(String, String)> = graph
235        .edge_keys()
236        .into_iter()
237        .filter(|edge| edge.v == id1 || edge.w == id1)
238        .map(|edge| (edge.v, edge.w))
239        .collect();
240    let edges2: Vec<(String, String)> = graph
241        .edge_keys()
242        .into_iter()
243        .filter(|edge| edge.v == id2 || edge.w == id2)
244        .map(|edge| (edge.v, edge.w))
245        .collect();
246
247    let edges1_prim: Vec<(String, String)> = edges1
248        .into_iter()
249        .map(|(v, w)| {
250            (
251                if v == id1 { id2.to_string() } else { v },
252                // Mermaid's `findCommonEdges(...)` has an asymmetry here: it maps the `w` side
253                // back to `id1` rather than `id2` (Mermaid@11.12.2).
254                if w == id1 { id1.to_string() } else { w },
255            )
256        })
257        .collect();
258
259    let mut out = Vec::new();
260    for e1 in edges1_prim {
261        if edges2.contains(&e1) {
262            out.push(e1);
263        }
264    }
265    out
266}
267
268fn flowchart_find_non_cluster_child(
269    id: &str,
270    graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
271    cluster_id: &str,
272) -> Option<String> {
273    let children = graph.children(id);
274    if children.is_empty() {
275        return Some(id.to_string());
276    }
277    let mut reserve: Option<String> = None;
278    for child in children {
279        let Some(child_id) = flowchart_find_non_cluster_child(child, graph, cluster_id) else {
280            continue;
281        };
282        let common_edges = flowchart_find_common_edges(graph, cluster_id, &child_id);
283        if !common_edges.is_empty() {
284            reserve = Some(child_id);
285        } else {
286            return Some(child_id);
287        }
288    }
289    reserve
290}
291
292fn adjust_flowchart_clusters_and_edges(graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>) {
293    use serde_json::Value;
294
295    fn is_descendant(
296        node_id: &str,
297        cluster_id: &str,
298        descendants: &std::collections::HashMap<String, Vec<String>>,
299    ) -> bool {
300        descendants
301            .get(cluster_id)
302            .is_some_and(|ds| ds.iter().any(|n| n == node_id))
303    }
304
305    let mut descendants: std::collections::HashMap<String, Vec<String>> =
306        std::collections::HashMap::new();
307    let mut cluster_db: std::collections::HashMap<String, FlowchartClusterDbEntry> =
308        std::collections::HashMap::new();
309
310    for id in graph.node_ids() {
311        if graph.children(&id).is_empty() {
312            continue;
313        }
314        descendants.insert(id.clone(), extract_descendants(&id, graph));
315        let anchor_id =
316            flowchart_find_non_cluster_child(&id, graph, &id).unwrap_or_else(|| id.clone());
317        cluster_db.insert(
318            id,
319            FlowchartClusterDbEntry {
320                anchor_id,
321                external_connections: false,
322            },
323        );
324    }
325
326    for id in cluster_db.keys().cloned().collect::<Vec<_>>() {
327        if graph.children(&id).is_empty() {
328            continue;
329        }
330        let mut has_external = false;
331        for e in graph.edges() {
332            let d1 = is_descendant(e.v.as_str(), id.as_str(), &descendants);
333            let d2 = is_descendant(e.w.as_str(), id.as_str(), &descendants);
334            if d1 ^ d2 {
335                has_external = true;
336                break;
337            }
338        }
339        if let Some(entry) = cluster_db.get_mut(&id) {
340            entry.external_connections = has_external;
341        }
342    }
343
344    for id in cluster_db.keys().cloned().collect::<Vec<_>>() {
345        let Some(non_cluster_child) = cluster_db.get(&id).map(|e| e.anchor_id.clone()) else {
346            continue;
347        };
348        let parent = graph.parent(&non_cluster_child);
349        if parent.is_some_and(|p| p != id.as_str())
350            && parent.is_some_and(|p| cluster_db.contains_key(p))
351            && parent.is_some_and(|p| !cluster_db.get(p).is_some_and(|e| e.external_connections))
352        {
353            if let Some(p) = parent {
354                if let Some(entry) = cluster_db.get_mut(&id) {
355                    entry.anchor_id = p.to_string();
356                }
357            }
358        }
359    }
360
361    fn get_anchor_id(
362        id: &str,
363        cluster_db: &std::collections::HashMap<String, FlowchartClusterDbEntry>,
364    ) -> String {
365        let Some(entry) = cluster_db.get(id) else {
366            return id.to_string();
367        };
368        if !entry.external_connections {
369            return id.to_string();
370        }
371        entry.anchor_id.clone()
372    }
373
374    let edge_keys = graph.edge_keys();
375    for ek in edge_keys {
376        if !cluster_db.contains_key(&ek.v) && !cluster_db.contains_key(&ek.w) {
377            continue;
378        }
379
380        let Some(mut edge_label) = graph.edge_by_key(&ek).cloned() else {
381            continue;
382        };
383
384        let v = get_anchor_id(&ek.v, &cluster_db);
385        let w = get_anchor_id(&ek.w, &cluster_db);
386
387        // Match Mermaid `adjustClustersAndEdges`: edges that touch cluster nodes are removed and
388        // re-inserted even when their endpoints do not change. This affects edge iteration order
389        // and therefore cycle-breaking determinism in Dagre's acyclic pass.
390        let _ = graph.remove_edge_key(&ek);
391
392        if v != ek.v {
393            if let Some(parent) = graph.parent(&v) {
394                if let Some(entry) = cluster_db.get_mut(parent) {
395                    entry.external_connections = true;
396                }
397            }
398            edge_label
399                .extras
400                .insert("fromCluster".to_string(), Value::String(ek.v.clone()));
401        }
402
403        if w != ek.w {
404            if let Some(parent) = graph.parent(&w) {
405                if let Some(entry) = cluster_db.get_mut(parent) {
406                    entry.external_connections = true;
407                }
408            }
409            edge_label
410                .extras
411                .insert("toCluster".to_string(), Value::String(ek.w.clone()));
412        }
413
414        graph.set_edge_named(v, w, ek.name, Some(edge_label));
415    }
416}
417
418fn copy_cluster(
419    cluster_id: &str,
420    graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
421    new_graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
422    root_id: &str,
423    descendants: &std::collections::HashMap<String, Vec<String>>,
424) {
425    let mut nodes: Vec<String> = graph
426        .children(cluster_id)
427        .iter()
428        .map(|s| s.to_string())
429        .collect();
430    if cluster_id != root_id {
431        nodes.push(cluster_id.to_string());
432    }
433
434    for node in nodes {
435        if !graph.has_node(&node) {
436            continue;
437        }
438
439        if !graph.children(&node).is_empty() {
440            copy_cluster(&node, graph, new_graph, root_id, descendants);
441        } else {
442            let data = graph.node(&node).cloned().unwrap_or_default();
443            new_graph.set_node(node.clone(), data);
444
445            if let Some(parent) = graph.parent(&node) {
446                if parent != root_id {
447                    new_graph.set_parent(node.clone(), parent.to_string());
448                }
449            }
450            if cluster_id != root_id && node != cluster_id {
451                new_graph.set_parent(node.clone(), cluster_id.to_string());
452            }
453
454            // Copy edges for this extracted cluster.
455            //
456            // Mermaid's implementation calls `graph.edges(node)` (note: on dagre-d3-es Graphlib,
457            // `edges()` ignores the argument and returns *all* edges). Because the source graph is
458            // mutated as nodes are removed, this makes edge insertion order sensitive to the leaf
459            // traversal order, which in turn can affect deterministic tie-breaking in Dagre's
460            // acyclic/ranking steps.
461            //
462            // Reference:
463            // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js`
464            for ek in graph.edge_keys() {
465                if !edge_in_cluster(&ek, root_id, descendants) {
466                    continue;
467                }
468                if new_graph.has_edge(&ek.v, &ek.w, ek.name.as_deref()) {
469                    continue;
470                }
471                let Some(lbl) = graph.edge_by_key(&ek).cloned() else {
472                    continue;
473                };
474                new_graph.set_edge_named(ek.v, ek.w, ek.name, Some(lbl));
475            }
476        }
477
478        let _ = graph.remove_node(&node);
479    }
480}
481
482fn extract_clusters_recursively(
483    graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
484    subgraphs_by_id: &std::collections::HashMap<String, FlowSubgraph>,
485    _effective_dir_by_id: &std::collections::HashMap<String, String>,
486    extracted: &mut std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
487    depth: usize,
488) {
489    if depth > 10 {
490        return;
491    }
492
493    let node_ids = graph.node_ids();
494    let mut descendants: std::collections::HashMap<String, Vec<String>> =
495        std::collections::HashMap::new();
496    for id in &node_ids {
497        if graph.children(id).is_empty() {
498            continue;
499        }
500        descendants.insert(id.clone(), extract_descendants(id, graph));
501    }
502
503    let mut external: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
504    for id in descendants.keys() {
505        let Some(ds) = descendants.get(id) else {
506            continue;
507        };
508        let mut has_external = false;
509        for e in graph.edges() {
510            let d1 = ds.contains(&e.v);
511            let d2 = ds.contains(&e.w);
512            if d1 ^ d2 {
513                has_external = true;
514                break;
515            }
516        }
517        external.insert(id.clone(), has_external);
518    }
519
520    let mut extracted_here: Vec<(String, Graph<NodeLabel, EdgeLabel, GraphLabel>)> = Vec::new();
521
522    let candidates: Vec<String> = node_ids
523        .into_iter()
524        .filter(|id| graph.has_node(id))
525        .filter(|id| !graph.children(id).is_empty())
526        // Mermaid's extractor does not require clusters to be root-level; it only checks
527        // `externalConnections` and `children.length > 0`, then recurses into extracted graphs.
528        //
529        // Reference:
530        // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js`
531        .filter(|id| !external.get(id).copied().unwrap_or(false))
532        .collect();
533
534    for id in candidates {
535        if !graph.has_node(&id) {
536            continue;
537        }
538        if graph.children(&id).is_empty() {
539            continue;
540        }
541
542        let mut cluster_graph: Graph<NodeLabel, EdgeLabel, GraphLabel> = Graph::new(GraphOptions {
543            multigraph: true,
544            compound: true,
545            directed: true,
546        });
547
548        // Mermaid's `extractor(...)` uses:
549        // - `clusterData.dir` when explicitly set for the subgraph
550        // - otherwise: toggle relative to the current graph's rankdir (TB<->LR)
551        let dir = subgraphs_by_id
552            .get(&id)
553            .and_then(|sg| sg.dir.as_deref())
554            .map(str::trim)
555            .filter(|s| !s.is_empty())
556            .map(normalize_dir)
557            .unwrap_or_else(|| toggled_dir(flow_dir_from_rankdir(graph.graph().rankdir)));
558
559        cluster_graph.set_graph(GraphLabel {
560            rankdir: dir_to_rankdir(&dir),
561            // Mermaid's cluster extractor initializes subgraphs with a fixed dagre config
562            // (nodesep/ranksep=50, marginx/marginy=8). Before each recursive render Mermaid then
563            // overrides `nodesep` to the parent graph value and `ranksep` to `parent.ranksep + 25`.
564            //
565            // We model that in headless mode by keeping the extractor defaults here, then applying
566            // the per-depth override inside `layout_graph_with_recursive_clusters(...)` right
567            // before laying out each extracted graph.
568            //
569            // Reference:
570            // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js`
571            // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js`
572            nodesep: 50.0,
573            ranksep: 50.0,
574            marginx: 8.0,
575            marginy: 8.0,
576            acyclicer: None,
577            ..Default::default()
578        });
579
580        copy_cluster(&id, graph, &mut cluster_graph, &id, &descendants);
581        extracted_here.push((id, cluster_graph));
582    }
583
584    for (id, mut g) in extracted_here {
585        extract_clusters_recursively(
586            &mut g,
587            subgraphs_by_id,
588            _effective_dir_by_id,
589            extracted,
590            depth + 1,
591        );
592        extracted.insert(id, g);
593    }
594}
595
596pub fn layout_flowchart_v2(
597    semantic: &Value,
598    effective_config: &MermaidConfig,
599    measurer: &dyn TextMeasurer,
600    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
601) -> Result<FlowchartV2Layout> {
602    let timing_enabled = std::env::var("MERMAN_FLOWCHART_LAYOUT_TIMING")
603        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
604        .unwrap_or(false);
605    let total_start = timing_enabled.then(std::time::Instant::now);
606
607    let deserialize_start = timing_enabled.then(std::time::Instant::now);
608    let model: FlowchartV2Model = crate::json::from_value_ref(semantic)?;
609    let deserialize = deserialize_start.map(|s| s.elapsed()).unwrap_or_default();
610
611    layout_flowchart_v2_with_model(
612        &model,
613        effective_config,
614        measurer,
615        math_renderer,
616        timing_enabled,
617        total_start,
618        deserialize,
619    )
620}
621
622pub fn layout_flowchart_v2_typed(
623    model: &FlowchartV2Model,
624    effective_config: &MermaidConfig,
625    measurer: &dyn TextMeasurer,
626    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
627) -> Result<FlowchartV2Layout> {
628    let timing_enabled = std::env::var("MERMAN_FLOWCHART_LAYOUT_TIMING")
629        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
630        .unwrap_or(false);
631    let total_start = timing_enabled.then(std::time::Instant::now);
632
633    layout_flowchart_v2_with_model(
634        model,
635        effective_config,
636        measurer,
637        math_renderer,
638        timing_enabled,
639        total_start,
640        std::time::Duration::default(),
641    )
642}
643
644fn layout_flowchart_v2_with_model(
645    model: &FlowchartV2Model,
646    effective_config: &MermaidConfig,
647    measurer: &dyn TextMeasurer,
648    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
649    timing_enabled: bool,
650    total_start: Option<std::time::Instant>,
651    deserialize: std::time::Duration,
652) -> Result<FlowchartV2Layout> {
653    #[derive(Debug, Default, Clone)]
654    struct FlowchartLayoutTimings {
655        total: std::time::Duration,
656        deserialize: std::time::Duration,
657        expand_self_loops: std::time::Duration,
658        build_graph: std::time::Duration,
659        extract_clusters: std::time::Duration,
660        dom_order: std::time::Duration,
661        layout_recursive: std::time::Duration,
662        dagre_calls: u32,
663        dagre_total: std::time::Duration,
664        place_graph: std::time::Duration,
665        build_output: std::time::Duration,
666    }
667
668    let mut timings = FlowchartLayoutTimings::default();
669    timings.deserialize = deserialize;
670
671    let effective_config_value = effective_config.as_value();
672
673    // Mermaid's dagre adapter expands self-loop edges into a chain of two special label nodes plus
674    // three edges. This avoids `v == w` edges in Dagre and is required for SVG parity (Mermaid
675    // uses `*-cyclic-special-*` ids when rendering self-loops).
676    let expand_self_loops_start = timing_enabled.then(std::time::Instant::now);
677    let mut render_edges: Vec<FlowEdge> = Vec::new();
678    let mut self_loop_label_node_ids: Vec<String> = Vec::new();
679    let mut self_loop_label_node_id_set: std::collections::HashSet<String> =
680        std::collections::HashSet::new();
681    for e in &model.edges {
682        if e.from != e.to {
683            render_edges.push(e.clone());
684            continue;
685        }
686
687        let node_id = e.from.clone();
688        let special_id_1 = format!("{node_id}---{node_id}---1");
689        let special_id_2 = format!("{node_id}---{node_id}---2");
690        if self_loop_label_node_id_set.insert(special_id_1.clone()) {
691            self_loop_label_node_ids.push(special_id_1.clone());
692        }
693        if self_loop_label_node_id_set.insert(special_id_2.clone()) {
694            self_loop_label_node_ids.push(special_id_2.clone());
695        }
696
697        let mut edge1 = e.clone();
698        edge1.id = format!("{node_id}-cyclic-special-1");
699        edge1.from = node_id.clone();
700        edge1.to = special_id_1.clone();
701        edge1.label = None;
702        edge1.label_type = None;
703        edge1.edge_type = Some("arrow_open".to_string());
704
705        let mut edge_mid = e.clone();
706        edge_mid.id = format!("{node_id}-cyclic-special-mid");
707        edge_mid.from = special_id_1.clone();
708        edge_mid.to = special_id_2.clone();
709        edge_mid.edge_type = Some("arrow_open".to_string());
710
711        let mut edge2 = e.clone();
712        edge2.id = format!("{node_id}-cyclic-special-2");
713        edge2.from = special_id_2.clone();
714        edge2.to = node_id.clone();
715        // Mermaid clears the label text on the end segments, but keeps the label (if any) on the
716        // mid edge (`edgeMid` is a structuredClone of the original edge without label changes).
717        edge1.label = Some(String::new());
718        edge2.label = Some(String::new());
719
720        render_edges.push(edge1);
721        render_edges.push(edge_mid);
722        render_edges.push(edge2);
723    }
724    if let Some(s) = expand_self_loops_start {
725        timings.expand_self_loops = s.elapsed();
726    }
727
728    let build_graph_start = timing_enabled.then(std::time::Instant::now);
729
730    let nodesep = config_f64(effective_config_value, &["flowchart", "nodeSpacing"]).unwrap_or(50.0);
731    let ranksep = config_f64(effective_config_value, &["flowchart", "rankSpacing"]).unwrap_or(50.0);
732    // Mermaid's default config sets `flowchart.padding` to 15.
733    let node_padding =
734        config_f64(effective_config_value, &["flowchart", "padding"]).unwrap_or(15.0);
735    // Used by a few flowchart-v2 shapes (notably `forkJoin.ts`) to inflate Dagre node dimensions.
736    // Mermaid default config sets `state.padding` to 8.
737    let state_padding = config_f64(effective_config_value, &["state", "padding"]).unwrap_or(8.0);
738    let wrapping_width =
739        config_f64(effective_config_value, &["flowchart", "wrappingWidth"]).unwrap_or(200.0);
740    // Mermaid measures edge labels via `createText(...)` without overriding the default
741    // wrapping width (200px), independent of `flowchart.wrappingWidth`.
742    let edge_label_wrapping_width = 200.0;
743    // Mermaid@11.12.2 renders subgraph titles via the `createText(...)` path and applies a default
744    // wrapping width of 200px (even when `labelType=text` and `htmlLabels=false`), which results
745    // in `<tspan>`-wrapped titles for long words. Match that behavior in headless metrics.
746    let cluster_title_wrapping_width = 200.0;
747    // Mermaid flowchart-v2 uses the global `htmlLabels` toggle for *node* labels, while
748    // subgraph titles + edge labels follow `flowchart.htmlLabels` (falling back to the global
749    // toggle when unset).
750    let node_html_labels = effective_config_value
751        .get("htmlLabels")
752        .and_then(Value::as_bool)
753        .unwrap_or(true);
754    let edge_html_labels = effective_config_value
755        .get("flowchart")
756        .and_then(|v| v.get("htmlLabels"))
757        .and_then(Value::as_bool)
758        .unwrap_or(node_html_labels);
759    let cluster_html_labels = edge_html_labels;
760    let node_wrap_mode = if node_html_labels {
761        WrapMode::HtmlLike
762    } else {
763        WrapMode::SvgLike
764    };
765    let cluster_wrap_mode = if cluster_html_labels {
766        WrapMode::HtmlLike
767    } else {
768        WrapMode::SvgLike
769    };
770    let edge_wrap_mode = if edge_html_labels {
771        WrapMode::HtmlLike
772    } else {
773        WrapMode::SvgLike
774    };
775    // Mermaid FlowDB encodes subgraph nodes with a fixed `padding: 8` in `data4Layout.nodes`.
776    // That value is separate from `flowchart.padding` (node padding) and `nodeSpacing`/`rankSpacing`.
777    let cluster_padding = 8.0;
778    let title_margin_top = config_f64(
779        effective_config_value,
780        &["flowchart", "subGraphTitleMargin", "top"],
781    )
782    .unwrap_or(0.0);
783    let title_margin_bottom = config_f64(
784        effective_config_value,
785        &["flowchart", "subGraphTitleMargin", "bottom"],
786    )
787    .unwrap_or(0.0);
788    let title_total_margin = title_margin_top + title_margin_bottom;
789    let y_shift = title_total_margin / 2.0;
790    let inherit_dir = effective_config_value
791        .get("flowchart")
792        .and_then(|v| v.get("inheritDir"))
793        .and_then(Value::as_bool)
794        .unwrap_or(false);
795
796    fn normalize_css_font_family(font_family: &str) -> String {
797        let s = font_family.trim().trim_end_matches(';').trim();
798        if s.is_empty() {
799            return String::new();
800        }
801
802        let mut parts: Vec<String> = Vec::new();
803        let mut cur = String::new();
804        let mut in_single = false;
805        let mut in_double = false;
806
807        for ch in s.chars() {
808            match ch {
809                '\'' if !in_double => {
810                    in_single = !in_single;
811                    cur.push(ch);
812                }
813                '"' if !in_single => {
814                    in_double = !in_double;
815                    cur.push(ch);
816                }
817                ',' if !in_single && !in_double => {
818                    let p = cur.trim();
819                    if !p.is_empty() {
820                        parts.push(p.to_string());
821                    }
822                    cur.clear();
823                }
824                _ => cur.push(ch),
825            }
826        }
827
828        let p = cur.trim();
829        if !p.is_empty() {
830            parts.push(p.to_string());
831        }
832        parts.join(",")
833    }
834
835    fn parse_font_size_px(v: &serde_json::Value) -> Option<f64> {
836        if let Some(n) = v.as_f64() {
837            return Some(n);
838        }
839        if let Some(n) = v.as_i64() {
840            return Some(n as f64);
841        }
842        if let Some(n) = v.as_u64() {
843            return Some(n as f64);
844        }
845        let s = v.as_str()?.trim();
846        if s.is_empty() {
847            return None;
848        }
849        let mut num = String::new();
850        for (idx, ch) in s.chars().enumerate() {
851            if ch.is_ascii_digit() {
852                num.push(ch);
853                continue;
854            }
855            if idx == 0 && (ch == '-' || ch == '+') {
856                num.push(ch);
857                continue;
858            }
859            break;
860        }
861        if num.trim().is_empty() {
862            return None;
863        }
864        num.parse::<f64>().ok()
865    }
866
867    let default_theme_font_family = "\"trebuchet ms\",verdana,arial,sans-serif".to_string();
868    let theme_font_family =
869        config_string(effective_config_value, &["themeVariables", "fontFamily"])
870            .map(|s| normalize_css_font_family(&s));
871    let top_font_family = config_string(effective_config_value, &["fontFamily"])
872        .map(|s| normalize_css_font_family(&s));
873    let font_family = Some(match (top_font_family, theme_font_family) {
874        (Some(top), Some(theme)) if theme == default_theme_font_family => top,
875        (_, Some(theme)) => theme,
876        (Some(top), None) => top,
877        (None, None) => default_theme_font_family,
878    });
879    let font_size = effective_config_value
880        .get("themeVariables")
881        .and_then(|tv| tv.get("fontSize"))
882        .and_then(parse_font_size_px)
883        .unwrap_or(16.0);
884    let font_weight = config_string(effective_config_value, &["fontWeight"]);
885    let text_style = TextStyle {
886        font_family,
887        font_size,
888        font_weight,
889    };
890
891    let diagram_direction = normalize_dir(model.direction.as_deref().unwrap_or("TB"));
892    let has_subgraphs = !model.subgraphs.is_empty();
893    let mut subgraphs_by_id: std::collections::HashMap<String, FlowSubgraph> =
894        std::collections::HashMap::new();
895    for sg in &model.subgraphs {
896        subgraphs_by_id.insert(sg.id.clone(), sg.clone());
897    }
898    let subgraph_ids: std::collections::HashSet<&str> =
899        model.subgraphs.iter().map(|sg| sg.id.as_str()).collect();
900    let subgraph_id_set: std::collections::HashSet<String> =
901        model.subgraphs.iter().map(|sg| sg.id.clone()).collect();
902    let mut g: Graph<NodeLabel, EdgeLabel, GraphLabel> = Graph::new(GraphOptions {
903        multigraph: true,
904        // Mermaid's Dagre adapter always enables `compound: true`, even if there are no explicit
905        // subgraphs. This also allows `nestingGraph.run` to connect components during ranking.
906        compound: true,
907        directed: true,
908    });
909    g.set_graph(GraphLabel {
910        rankdir: rank_dir_from_flow(&diagram_direction),
911        nodesep,
912        ranksep,
913        marginx: 8.0,
914        marginy: 8.0,
915        acyclicer: None,
916        ..Default::default()
917    });
918
919    let mut empty_subgraph_ids: Vec<String> = Vec::new();
920    let mut cluster_node_labels: std::collections::HashMap<String, NodeLabel> =
921        std::collections::HashMap::new();
922    for sg in &model.subgraphs {
923        if sg.nodes.is_empty() {
924            // Mermaid renders empty subgraphs as regular nodes. Keep the semantic `subgraph`
925            // definition around for styling/title, but size + lay it out as a leaf node.
926            empty_subgraph_ids.push(sg.id.clone());
927            continue;
928        }
929        // Mermaid does not pre-size compound (subgraph) nodes based on title metrics for Dagre
930        // layout. Their dimensions are computed from children (border nodes) and then adjusted at
931        // render time for title width and configured margins.
932        cluster_node_labels.insert(sg.id.clone(), NodeLabel::default());
933    }
934
935    let mut leaf_node_labels: std::collections::HashMap<String, NodeLabel> =
936        std::collections::HashMap::new();
937    let mut leaf_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
938    leaf_label_metrics_by_id.reserve(model.nodes.len() + empty_subgraph_ids.len());
939    for n in &model.nodes {
940        // Mermaid treats the subgraph id as the "group node" id (a cluster can be referenced in
941        // edges). Avoid introducing a separate leaf node that would collide with the cluster node
942        // of the same id.
943        if subgraph_ids.contains(n.id.as_str()) {
944            continue;
945        }
946        let raw_label = n.label.as_deref().unwrap_or(&n.id);
947        let label_type = n.label_type.as_deref().unwrap_or("text");
948        let node_text_style = flowchart_effective_text_style_for_node_classes(
949            &text_style,
950            &model.class_defs,
951            &n.classes,
952            &n.styles,
953        );
954        let mut metrics = flowchart_label_metrics_for_layout(
955            measurer,
956            raw_label,
957            label_type,
958            node_text_style.as_ref(),
959            Some(wrapping_width),
960            node_wrap_mode,
961            effective_config,
962            math_renderer,
963        );
964        let span_css_height_parity =
965            flowchart_node_has_span_css_height_parity(&model.class_defs, &n.classes);
966        if span_css_height_parity {
967            crate::text::flowchart_apply_mermaid_styled_node_height_parity(
968                &mut metrics,
969                node_text_style.as_ref(),
970            );
971        }
972        leaf_label_metrics_by_id.insert(n.id.clone(), (metrics.width, metrics.height));
973        let (width, height) = node_layout_dimensions(
974            n.layout_shape.as_deref(),
975            metrics,
976            node_padding,
977            state_padding,
978            node_wrap_mode,
979            n.icon.as_deref(),
980            n.img.as_deref(),
981            n.pos.as_deref(),
982            n.asset_width,
983            n.asset_height,
984        );
985        leaf_node_labels.insert(
986            n.id.clone(),
987            NodeLabel {
988                width,
989                height,
990                ..Default::default()
991            },
992        );
993    }
994    for sg in &model.subgraphs {
995        if !sg.nodes.is_empty() {
996            continue;
997        }
998        let label_type = sg.label_type.as_deref().unwrap_or("text");
999        let sg_text_style = flowchart_effective_text_style_for_classes(
1000            &text_style,
1001            &model.class_defs,
1002            &sg.classes,
1003            &[],
1004        );
1005        let metrics = flowchart_label_metrics_for_layout(
1006            measurer,
1007            &sg.title,
1008            label_type,
1009            sg_text_style.as_ref(),
1010            Some(cluster_title_wrapping_width),
1011            node_wrap_mode,
1012            effective_config,
1013            math_renderer,
1014        );
1015        leaf_label_metrics_by_id.insert(sg.id.clone(), (metrics.width, metrics.height));
1016        let (width, height) = node_layout_dimensions(
1017            Some("squareRect"),
1018            metrics,
1019            cluster_padding,
1020            state_padding,
1021            node_wrap_mode,
1022            None,
1023            None,
1024            None,
1025            None,
1026            None,
1027        );
1028        leaf_node_labels.insert(
1029            sg.id.clone(),
1030            NodeLabel {
1031                width,
1032                height,
1033                ..Default::default()
1034            },
1035        );
1036    }
1037
1038    // Mermaid constructs the Dagre graph by:
1039    // 1) inserting subgraph (cluster) nodes first (in reverse `subgraphs[]` order), then
1040    // 2) inserting vertex nodes (in FlowDB `Map` insertion order),
1041    // and setting `parentId` as each node is inserted.
1042    //
1043    // Matching this order matters because Graphlib insertion order can affect compound-graph
1044    // child ordering, anchor selection and deterministic tie-breaking in layout.
1045    let mut inserted: std::collections::HashSet<String> = std::collections::HashSet::new();
1046    let mut parent_assigned: std::collections::HashSet<String> = std::collections::HashSet::new();
1047    let insert_one = |id: &str,
1048                      g: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1049                      inserted: &mut std::collections::HashSet<String>| {
1050        if inserted.contains(id) {
1051            return;
1052        }
1053        if let Some(lbl) = cluster_node_labels.get(id).cloned() {
1054            g.set_node(id.to_string(), lbl);
1055            inserted.insert(id.to_string());
1056            return;
1057        }
1058        if let Some(lbl) = leaf_node_labels.get(id).cloned() {
1059            g.set_node(id.to_string(), lbl);
1060            inserted.insert(id.to_string());
1061        }
1062    };
1063
1064    if has_subgraphs {
1065        // Match Mermaid's `FlowDB.getData()` parent assignment: build `parentId` by iterating
1066        // subgraphs in reverse order and recording each membership.
1067        let mut parent_by_id: std::collections::HashMap<String, String> =
1068            std::collections::HashMap::new();
1069        for sg in model.subgraphs.iter().rev() {
1070            for child in &sg.nodes {
1071                parent_by_id.insert(child.clone(), sg.id.clone());
1072            }
1073        }
1074
1075        let insert_with_parent =
1076            |id: &str,
1077             g: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1078             inserted: &mut std::collections::HashSet<String>,
1079             parent_assigned: &mut std::collections::HashSet<String>| {
1080                insert_one(id, g, inserted);
1081                if !parent_assigned.insert(id.to_string()) {
1082                    return;
1083                }
1084                if let Some(p) = parent_by_id.get(id).cloned() {
1085                    g.set_parent(id.to_string(), p);
1086                }
1087            };
1088
1089        for sg in model.subgraphs.iter().rev() {
1090            insert_with_parent(sg.id.as_str(), &mut g, &mut inserted, &mut parent_assigned);
1091        }
1092        for n in &model.nodes {
1093            insert_with_parent(n.id.as_str(), &mut g, &mut inserted, &mut parent_assigned);
1094        }
1095        for id in &model.vertex_calls {
1096            insert_with_parent(id.as_str(), &mut g, &mut inserted, &mut parent_assigned);
1097        }
1098    } else {
1099        // No subgraphs: insertion order still matters for deterministic Dagre tie-breaking.
1100        for n in &model.nodes {
1101            insert_one(n.id.as_str(), &mut g, &mut inserted);
1102        }
1103        for id in &model.vertex_calls {
1104            insert_one(id.as_str(), &mut g, &mut inserted);
1105        }
1106    }
1107
1108    // Materialize self-loop helper label nodes and place them in the same parent cluster as the
1109    // base node (if any), matching Mermaid `@11.12.2` dagre layout adapter behavior.
1110    for id in &self_loop_label_node_ids {
1111        if !g.has_node(id) {
1112            g.set_node(
1113                id.clone(),
1114                NodeLabel {
1115                    // Mermaid initializes these labelRect nodes at 10x10, but then immediately
1116                    // runs `insertNode(...)` + `updateNodeBounds(...)` before Dagre layout. For an
1117                    // empty `labelRect`, the measured bbox collapses to ~0.1x0.1 and that is what
1118                    // Dagre actually sees for spacing. Match that here for layout parity.
1119                    width: 0.1_f32 as f64,
1120                    height: 0.1_f32 as f64,
1121                    ..Default::default()
1122                },
1123            );
1124        }
1125        let Some((base, _)) = id.split_once("---") else {
1126            continue;
1127        };
1128        if let Some(p) = g.parent(base) {
1129            g.set_parent(id.clone(), p.to_string());
1130        }
1131    }
1132
1133    let effective_dir_by_id = if has_subgraphs {
1134        compute_effective_dir_by_id(&subgraphs_by_id, &g, &diagram_direction, inherit_dir)
1135    } else {
1136        HashMap::new()
1137    };
1138
1139    // Map SVG edge ids to the multigraph key used by the Dagre layout graph. Most edges use their
1140    // `id` as the key, but Mermaid uses distinct keys for the self-loop special edges and we also
1141    // want deterministic ordering under our BTree-backed graph storage.
1142    let mut edge_key_by_id: HashMap<String, String> = HashMap::new();
1143    let mut edge_id_by_key: HashMap<String, String> = HashMap::new();
1144
1145    for e in &render_edges {
1146        // Mermaid uses distinct graphlib multigraph keys for self-loop helper edges.
1147        // Reference: `packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js`
1148        let edge_key = if let Some(prefix) = e.id.strip_suffix("-cyclic-special-1") {
1149            format!("{prefix}-cyclic-special-0")
1150        } else if let Some(prefix) = e.id.strip_suffix("-cyclic-special-mid") {
1151            format!("{prefix}-cyclic-special-1")
1152        } else if let Some(prefix) = e.id.strip_suffix("-cyclic-special-2") {
1153            // Mermaid contains this typo in the edge key (note the `<`):
1154            // `nodeId + '-cyc<lic-special-2'`
1155            format!("{prefix}-cyc<lic-special-2")
1156        } else {
1157            e.id.clone()
1158        };
1159        edge_key_by_id.insert(e.id.clone(), edge_key.clone());
1160        edge_id_by_key.insert(edge_key.clone(), e.id.clone());
1161
1162        let from = e.from.clone();
1163        let to = e.to.clone();
1164
1165        if edge_label_is_non_empty(e) {
1166            let label_text = e.label.as_deref().unwrap_or_default();
1167            let label_type = e.label_type.as_deref().unwrap_or("text");
1168            let edge_text_style = flowchart_effective_text_style_for_classes(
1169                &text_style,
1170                &model.class_defs,
1171                &e.classes,
1172                &e.style,
1173            );
1174            let metrics = if label_type == "markdown" && edge_wrap_mode != WrapMode::HtmlLike {
1175                crate::text::measure_wrapped_markdown_with_flowchart_bold_deltas(
1176                    measurer,
1177                    label_text,
1178                    edge_text_style.as_ref(),
1179                    Some(edge_label_wrapping_width),
1180                    edge_wrap_mode,
1181                )
1182            } else {
1183                flowchart_label_metrics_for_layout(
1184                    measurer,
1185                    label_text,
1186                    label_type,
1187                    edge_text_style.as_ref(),
1188                    Some(edge_label_wrapping_width),
1189                    edge_wrap_mode,
1190                    effective_config,
1191                    math_renderer,
1192                )
1193            };
1194            let (label_width, label_height) = if edge_html_labels {
1195                (metrics.width.max(1.0), metrics.height.max(1.0))
1196            } else {
1197                // Mermaid's SVG edge-labels include a padded background rect (+2px left/right and
1198                // +2px top/bottom).
1199                (
1200                    (metrics.width + 4.0).max(1.0),
1201                    (metrics.height + 4.0).max(1.0),
1202                )
1203            };
1204
1205            let minlen = e.length.max(1);
1206            let el = EdgeLabel {
1207                width: label_width,
1208                height: label_height,
1209                labelpos: LabelPos::C,
1210                // Dagre layout defaults `labeloffset` to 10 when unspecified.
1211                labeloffset: 10.0,
1212                minlen,
1213                weight: 1.0,
1214                ..Default::default()
1215            };
1216
1217            g.set_edge_named(from, to, Some(edge_key), Some(el));
1218        } else {
1219            let el = EdgeLabel {
1220                width: 0.0,
1221                height: 0.0,
1222                labelpos: LabelPos::C,
1223                // Dagre layout defaults `labeloffset` to 10 when unspecified.
1224                labeloffset: 10.0,
1225                minlen: e.length.max(1),
1226                weight: 1.0,
1227                ..Default::default()
1228            };
1229            g.set_edge_named(from, to, Some(edge_key), Some(el));
1230        }
1231    }
1232
1233    if has_subgraphs {
1234        adjust_flowchart_clusters_and_edges(&mut g);
1235    }
1236
1237    let mut edge_endpoints_by_id: HashMap<String, (String, String)> = HashMap::new();
1238    for ek in g.edge_keys() {
1239        let Some(edge_key) = ek.name.as_deref() else {
1240            continue;
1241        };
1242        let edge_id = edge_id_by_key
1243            .get(edge_key)
1244            .cloned()
1245            .unwrap_or_else(|| edge_key.to_string());
1246        edge_endpoints_by_id.insert(edge_id, (ek.v.clone(), ek.w.clone()));
1247    }
1248
1249    if let Some(s) = build_graph_start {
1250        timings.build_graph = s.elapsed();
1251    }
1252
1253    let mut extracted_graphs: std::collections::HashMap<
1254        String,
1255        Graph<NodeLabel, EdgeLabel, GraphLabel>,
1256    > = std::collections::HashMap::new();
1257    if has_subgraphs {
1258        let extract_start = timing_enabled.then(std::time::Instant::now);
1259        extract_clusters_recursively(
1260            &mut g,
1261            &subgraphs_by_id,
1262            &effective_dir_by_id,
1263            &mut extracted_graphs,
1264            0,
1265        );
1266        if let Some(s) = extract_start {
1267            timings.extract_clusters = s.elapsed();
1268        }
1269    }
1270
1271    // Mermaid's flowchart-v2 renderer inserts node DOM elements in `graph.nodes()` order before
1272    // running Dagre layout, including for recursively extracted cluster graphs. Capture that
1273    // insertion order per root so the headless SVG matches strict DOM expectations.
1274    let mut dom_node_order_by_root: std::collections::HashMap<String, Vec<String>> =
1275        std::collections::HashMap::new();
1276    let dom_order_start = timing_enabled.then(std::time::Instant::now);
1277    dom_node_order_by_root.insert(String::new(), g.node_ids());
1278    for (id, cg) in &extracted_graphs {
1279        dom_node_order_by_root.insert(id.clone(), cg.node_ids());
1280    }
1281    if let Some(s) = dom_order_start {
1282        timings.dom_order = s.elapsed();
1283    }
1284
1285    type Rect = merman_core::geom::Box2;
1286
1287    fn extracted_graph_bbox_rect(
1288        g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1289        title_total_margin: f64,
1290        extracted: &std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1291        subgraph_id_set: &std::collections::HashSet<String>,
1292    ) -> Option<Rect> {
1293        fn graph_content_rect(
1294            g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1295            extracted: &std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1296            subgraph_id_set: &std::collections::HashSet<String>,
1297            title_total_margin: f64,
1298        ) -> Option<Rect> {
1299            let mut out: Option<Rect> = None;
1300            for id in g.node_ids() {
1301                let Some(n) = g.node(&id) else { continue };
1302                let (Some(x), Some(y)) = (n.x, n.y) else {
1303                    continue;
1304                };
1305                let mut height = n.height;
1306                let is_cluster_node = extracted.contains_key(&id) && g.children(&id).is_empty();
1307                let is_non_recursive_cluster =
1308                    subgraph_id_set.contains(&id) && !g.children(&id).is_empty();
1309
1310                // Mermaid increases cluster node height by `subGraphTitleTotalMargin` *after* Dagre
1311                // layout (just before rendering), and `updateNodeBounds(...)` measures the DOM
1312                // bbox after that expansion. Mirror that here for non-recursive clusters.
1313                //
1314                // For leaf clusterNodes (recursively rendered clusters), the node's width/height
1315                // comes directly from `updateNodeBounds(...)`, so do not add margins again.
1316                if !is_cluster_node && is_non_recursive_cluster && title_total_margin > 0.0 {
1317                    height = (height + title_total_margin).max(1.0);
1318                }
1319
1320                let r = Rect::from_center(x, y, n.width, height);
1321                if let Some(ref mut cur) = out {
1322                    cur.union(r);
1323                } else {
1324                    out = Some(r);
1325                }
1326            }
1327            for ek in g.edge_keys() {
1328                let Some(e) = g.edge_by_key(&ek) else {
1329                    continue;
1330                };
1331                let (Some(x), Some(y)) = (e.x, e.y) else {
1332                    continue;
1333                };
1334                if e.width <= 0.0 && e.height <= 0.0 {
1335                    continue;
1336                }
1337                let r = Rect::from_center(x, y, e.width, e.height);
1338                if let Some(ref mut cur) = out {
1339                    cur.union(r);
1340                } else {
1341                    out = Some(r);
1342                }
1343            }
1344            out
1345        }
1346
1347        graph_content_rect(g, extracted, subgraph_id_set, title_total_margin)
1348    }
1349
1350    fn apply_mermaid_subgraph_title_shifts(
1351        graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1352        extracted: &std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1353        subgraph_id_set: &std::collections::HashSet<String>,
1354        y_shift: f64,
1355    ) {
1356        if y_shift.abs() < 1e-9 {
1357            return;
1358        }
1359
1360        // Mermaid v11.12.2 adjusts Y positions after Dagre layout:
1361        // - regular nodes: +subGraphTitleTotalMargin/2
1362        // - clusterNode nodes (recursively rendered clusters): +subGraphTitleTotalMargin
1363        // - pure cluster nodes (non-recursive clusters): no y-shift (but height grows elsewhere)
1364        for id in graph.node_ids() {
1365            // A cluster is only a Mermaid "clusterNode" placeholder if it is a leaf in the
1366            // current graph. Extracted graphs contain an injected parent cluster node with the
1367            // same id (and children), which must follow the pure-cluster path.
1368            let is_cluster_node = extracted.contains_key(&id) && graph.children(&id).is_empty();
1369            let delta_y = if is_cluster_node {
1370                y_shift * 2.0
1371            } else if subgraph_id_set.contains(&id) && !graph.children(&id).is_empty() {
1372                0.0
1373            } else {
1374                y_shift
1375            };
1376            if delta_y.abs() > 1e-9 {
1377                let Some(y) = graph.node(&id).and_then(|n| n.y) else {
1378                    continue;
1379                };
1380                if let Some(n) = graph.node_mut(&id) {
1381                    n.y = Some(y + delta_y);
1382                }
1383            }
1384        }
1385
1386        // Mermaid shifts all edge points and the edge label position by +subGraphTitleTotalMargin/2.
1387        for ek in graph.edge_keys() {
1388            let Some(e) = graph.edge_mut_by_key(&ek) else {
1389                continue;
1390            };
1391            if let Some(y) = e.y {
1392                e.y = Some(y + y_shift);
1393            }
1394            for p in &mut e.points {
1395                p.y += y_shift;
1396            }
1397        }
1398    }
1399
1400    fn layout_graph_with_recursive_clusters(
1401        graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1402        graph_cluster_id: Option<&str>,
1403        extracted: &mut std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1404        depth: usize,
1405        subgraph_id_set: &std::collections::HashSet<String>,
1406        y_shift: f64,
1407        cluster_node_labels: &std::collections::HashMap<String, NodeLabel>,
1408        title_total_margin: f64,
1409        timings: &mut FlowchartLayoutTimings,
1410        timing_enabled: bool,
1411    ) {
1412        if depth > 10 {
1413            if timing_enabled {
1414                timings.dagre_calls += 1;
1415                let start = std::time::Instant::now();
1416                dugong::layout_dagreish(graph);
1417                timings.dagre_total += start.elapsed();
1418            } else {
1419                dugong::layout_dagreish(graph);
1420            }
1421            apply_mermaid_subgraph_title_shifts(graph, extracted, subgraph_id_set, y_shift);
1422            return;
1423        }
1424
1425        // Layout child graphs first, then update the corresponding node sizes before laying out
1426        // the parent graph. This mirrors Mermaid: `recursiveRender` computes clusterNode sizes
1427        // before `dagreLayout(graph)`.
1428        let ids = graph.node_ids();
1429        for id in ids {
1430            if !extracted.contains_key(&id) {
1431                continue;
1432            }
1433            // Only treat leaf cluster nodes as "clusterNode" placeholders. RecursiveRender adds
1434            // the parent cluster node (with children) into the child graph before layout, so the
1435            // cluster id will exist there but should not recurse back into itself.
1436            if !graph.children(&id).is_empty() {
1437                continue;
1438            }
1439            let mut child = match extracted.remove(&id) {
1440                Some(g) => g,
1441                None => continue,
1442            };
1443
1444            // Match Mermaid `recursiveRender` behavior: before laying out a recursively rendered
1445            // cluster graph, override `nodesep` to the parent graph spacing and `ranksep` to
1446            // `parent.ranksep + 25`. This compounds for nested recursive clusters (each recursion
1447            // level adds another +25).
1448            let parent_nodesep = graph.graph().nodesep;
1449            let parent_ranksep = graph.graph().ranksep;
1450            child.graph_mut().nodesep = parent_nodesep;
1451            child.graph_mut().ranksep = parent_ranksep + 25.0;
1452
1453            layout_graph_with_recursive_clusters(
1454                &mut child,
1455                Some(id.as_str()),
1456                extracted,
1457                depth + 1,
1458                subgraph_id_set,
1459                y_shift,
1460                cluster_node_labels,
1461                title_total_margin,
1462                timings,
1463                timing_enabled,
1464            );
1465
1466            // In Mermaid, `updateNodeBounds(...)` measures the recursively rendered `<g class="root">`
1467            // group. In that render path, the child graph contains a node matching the cluster id
1468            // (inserted via `graph.setNode(parentCluster.id, ...)`), whose computed compound bounds
1469            // correspond to the cluster box measured in the DOM.
1470            if let Some(r) =
1471                extracted_graph_bbox_rect(&child, title_total_margin, extracted, subgraph_id_set)
1472            {
1473                if let Some(n) = graph.node_mut(&id) {
1474                    n.width = r.width().max(1.0);
1475                    n.height = r.height().max(1.0);
1476                }
1477            } else if let Some(n_child) = child.node(&id) {
1478                if let Some(n) = graph.node_mut(&id) {
1479                    n.width = n_child.width.max(1.0);
1480                    n.height = n_child.height.max(1.0);
1481                }
1482            }
1483            extracted.insert(id, child);
1484        }
1485
1486        // Mermaid `recursiveRender` injects the parent cluster node into the child graph and
1487        // assigns it as the parent of nodes without an existing parent.
1488        if let Some(cluster_id) = graph_cluster_id {
1489            if !graph.has_node(cluster_id) {
1490                let lbl = cluster_node_labels
1491                    .get(cluster_id)
1492                    .cloned()
1493                    .unwrap_or_default();
1494                graph.set_node(cluster_id.to_string(), lbl);
1495            }
1496            let node_ids = graph.node_ids();
1497            for node_id in node_ids {
1498                if node_id == cluster_id {
1499                    continue;
1500                }
1501                if graph.parent(&node_id).is_none() {
1502                    graph.set_parent(node_id, cluster_id.to_string());
1503                }
1504            }
1505        }
1506
1507        if timing_enabled {
1508            timings.dagre_calls += 1;
1509            let start = std::time::Instant::now();
1510            dugong::layout_dagreish(graph);
1511            timings.dagre_total += start.elapsed();
1512        } else {
1513            dugong::layout_dagreish(graph);
1514        }
1515        apply_mermaid_subgraph_title_shifts(graph, extracted, subgraph_id_set, y_shift);
1516    }
1517
1518    let layout_start = timing_enabled.then(std::time::Instant::now);
1519    layout_graph_with_recursive_clusters(
1520        &mut g,
1521        None,
1522        &mut extracted_graphs,
1523        0,
1524        &subgraph_id_set,
1525        y_shift,
1526        &cluster_node_labels,
1527        title_total_margin,
1528        &mut timings,
1529        timing_enabled,
1530    );
1531    if let Some(s) = layout_start {
1532        timings.layout_recursive = s.elapsed();
1533    }
1534
1535    let mut leaf_rects: std::collections::HashMap<String, Rect> = std::collections::HashMap::new();
1536    let mut base_pos: std::collections::HashMap<String, (f64, f64)> =
1537        std::collections::HashMap::new();
1538    let mut edge_override_points: std::collections::HashMap<String, Vec<LayoutPoint>> =
1539        std::collections::HashMap::new();
1540    let mut edge_override_label: std::collections::HashMap<String, Option<LayoutLabel>> =
1541        std::collections::HashMap::new();
1542    let mut edge_override_from_cluster: std::collections::HashMap<String, Option<String>> =
1543        std::collections::HashMap::new();
1544    let mut edge_override_to_cluster: std::collections::HashMap<String, Option<String>> =
1545        std::collections::HashMap::new();
1546    #[cfg(feature = "flowchart_root_pack")]
1547    let mut edge_packed_shift: std::collections::HashMap<String, (f64, f64)> =
1548        std::collections::HashMap::new();
1549    #[cfg(not(feature = "flowchart_root_pack"))]
1550    let edge_packed_shift: std::collections::HashMap<String, (f64, f64)> =
1551        std::collections::HashMap::new();
1552
1553    let mut leaf_node_ids: std::collections::HashSet<String> = model
1554        .nodes
1555        .iter()
1556        .filter(|n| !subgraph_ids.contains(n.id.as_str()))
1557        .map(|n| n.id.clone())
1558        .collect();
1559    for id in &self_loop_label_node_ids {
1560        leaf_node_ids.insert(id.clone());
1561    }
1562    for id in &empty_subgraph_ids {
1563        leaf_node_ids.insert(id.clone());
1564    }
1565
1566    fn place_graph(
1567        graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1568        offset: (f64, f64),
1569        is_root: bool,
1570        edge_id_by_key: &std::collections::HashMap<String, String>,
1571        extracted_graphs: &std::collections::HashMap<
1572            String,
1573            Graph<NodeLabel, EdgeLabel, GraphLabel>,
1574        >,
1575        subgraph_ids: &std::collections::HashSet<&str>,
1576        leaf_node_ids: &std::collections::HashSet<String>,
1577        _title_total_margin: f64,
1578        base_pos: &mut std::collections::HashMap<String, (f64, f64)>,
1579        leaf_rects: &mut std::collections::HashMap<String, Rect>,
1580        cluster_rects_from_graph: &mut std::collections::HashMap<String, Rect>,
1581        extracted_cluster_rects: &mut std::collections::HashMap<String, Rect>,
1582        edge_override_points: &mut std::collections::HashMap<String, Vec<LayoutPoint>>,
1583        edge_override_label: &mut std::collections::HashMap<String, Option<LayoutLabel>>,
1584        edge_override_from_cluster: &mut std::collections::HashMap<String, Option<String>>,
1585        edge_override_to_cluster: &mut std::collections::HashMap<String, Option<String>>,
1586    ) {
1587        for id in graph.node_ids() {
1588            let Some(n) = graph.node(&id) else { continue };
1589            let x = n.x.unwrap_or(0.0) + offset.0;
1590            let y = n.y.unwrap_or(0.0) + offset.1;
1591            if leaf_node_ids.contains(&id) {
1592                base_pos.insert(id.clone(), (x, y));
1593                leaf_rects.insert(id.clone(), Rect::from_center(x, y, n.width, n.height));
1594                continue;
1595            }
1596        }
1597
1598        fn subtree_rect(
1599            graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1600            id: &str,
1601            visiting: &mut std::collections::HashSet<String>,
1602        ) -> Option<Rect> {
1603            if !visiting.insert(id.to_string()) {
1604                return None;
1605            }
1606            let mut out: Option<Rect> = None;
1607            for child in graph.children(id) {
1608                if let Some(n) = graph.node(child) {
1609                    if let (Some(x), Some(y)) = (n.x, n.y) {
1610                        let r = Rect::from_center(x, y, n.width, n.height);
1611                        if let Some(ref mut cur) = out {
1612                            cur.union(r);
1613                        } else {
1614                            out = Some(r);
1615                        }
1616                    }
1617                }
1618                if !graph.children(child).is_empty() {
1619                    if let Some(r) = subtree_rect(graph, child, visiting) {
1620                        if let Some(ref mut cur) = out {
1621                            cur.union(r);
1622                        } else {
1623                            out = Some(r);
1624                        }
1625                    }
1626                }
1627            }
1628            visiting.remove(id);
1629            out
1630        }
1631
1632        // Capture the layout-computed compound bounds for non-extracted clusters.
1633        //
1634        // Upstream Dagre computes compound-node geometry from border nodes and then removes the
1635        // border dummy nodes (`removeBorderNodes`). Our dugong parity pipeline mirrors that, so
1636        // prefer the compound node's own x/y/width/height when available.
1637        for id in graph.node_ids() {
1638            if !subgraph_ids.contains(id.as_str()) {
1639                continue;
1640            }
1641            if extracted_graphs.contains_key(&id) {
1642                continue;
1643            }
1644            if cluster_rects_from_graph.contains_key(&id) {
1645                continue;
1646            }
1647            if let Some(n) = graph.node(&id) {
1648                if let (Some(x), Some(y)) = (n.x, n.y) {
1649                    if n.width > 0.0 && n.height > 0.0 {
1650                        let mut r = Rect::from_center(x, y, n.width, n.height);
1651                        r.translate(offset.0, offset.1);
1652                        cluster_rects_from_graph.insert(id, r);
1653                        continue;
1654                    }
1655                }
1656            }
1657
1658            let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
1659            let Some(mut r) = subtree_rect(graph, &id, &mut visiting) else {
1660                continue;
1661            };
1662            r.translate(offset.0, offset.1);
1663            cluster_rects_from_graph.insert(id, r);
1664        }
1665
1666        for ek in graph.edge_keys() {
1667            let Some(edge_key) = ek.name.as_deref() else {
1668                continue;
1669            };
1670            let edge_id = edge_id_by_key
1671                .get(edge_key)
1672                .map(String::as_str)
1673                .unwrap_or(edge_key);
1674            let Some(lbl) = graph.edge_by_key(&ek) else {
1675                continue;
1676            };
1677
1678            if let (Some(x), Some(y)) = (lbl.x, lbl.y) {
1679                if lbl.width > 0.0 || lbl.height > 0.0 {
1680                    let lx = x + offset.0;
1681                    let ly = y + offset.1;
1682                    let leaf_id = format!("edge-label::{edge_id}");
1683                    base_pos.insert(leaf_id.clone(), (lx, ly));
1684                    leaf_rects.insert(leaf_id, Rect::from_center(lx, ly, lbl.width, lbl.height));
1685                }
1686            }
1687
1688            if !is_root {
1689                let points = lbl
1690                    .points
1691                    .iter()
1692                    .map(|p| LayoutPoint {
1693                        x: p.x + offset.0,
1694                        y: p.y + offset.1,
1695                    })
1696                    .collect::<Vec<_>>();
1697                let label_pos = match (lbl.x, lbl.y) {
1698                    (Some(x), Some(y)) if lbl.width > 0.0 || lbl.height > 0.0 => {
1699                        Some(LayoutLabel {
1700                            x: x + offset.0,
1701                            y: y + offset.1,
1702                            width: lbl.width,
1703                            height: lbl.height,
1704                        })
1705                    }
1706                    _ => None,
1707                };
1708                edge_override_points.insert(edge_id.to_string(), points);
1709                edge_override_label.insert(edge_id.to_string(), label_pos);
1710                let from_cluster = lbl
1711                    .extras
1712                    .get("fromCluster")
1713                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1714                let to_cluster = lbl
1715                    .extras
1716                    .get("toCluster")
1717                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1718                edge_override_from_cluster.insert(edge_id.to_string(), from_cluster);
1719                edge_override_to_cluster.insert(edge_id.to_string(), to_cluster);
1720            }
1721        }
1722
1723        for id in graph.node_ids() {
1724            // Only recurse into extracted graphs for leaf cluster nodes ("clusterNode" in Mermaid).
1725            // The recursively rendered graph itself also contains a node with the same id (the
1726            // parent cluster node injected before layout), which has children and must not recurse.
1727            if !graph.children(&id).is_empty() {
1728                continue;
1729            }
1730            let Some(child) = extracted_graphs.get(&id) else {
1731                continue;
1732            };
1733            let Some(n) = graph.node(&id) else {
1734                continue;
1735            };
1736            let (Some(px), Some(py)) = (n.x, n.y) else {
1737                continue;
1738            };
1739            let parent_x = px + offset.0;
1740            let parent_y = py + offset.1;
1741            let Some(cnode) = child.node(&id) else {
1742                continue;
1743            };
1744            let (Some(cx), Some(cy)) = (cnode.x, cnode.y) else {
1745                continue;
1746            };
1747            let child_offset = (parent_x - cx, parent_y - cy);
1748            // The extracted cluster's footprint in the parent graph is the clusterNode itself.
1749            // Our recursive layout step updates the parent graph's node `width/height` to match
1750            // Mermaid's `updateNodeBounds(...)` behavior (including any title margin). Avoid
1751            // adding `title_total_margin` again here.
1752            let r = Rect::from_center(parent_x, parent_y, n.width, n.height);
1753            extracted_cluster_rects.insert(id.clone(), r);
1754            place_graph(
1755                child,
1756                child_offset,
1757                false,
1758                edge_id_by_key,
1759                extracted_graphs,
1760                subgraph_ids,
1761                leaf_node_ids,
1762                _title_total_margin,
1763                base_pos,
1764                leaf_rects,
1765                cluster_rects_from_graph,
1766                extracted_cluster_rects,
1767                edge_override_points,
1768                edge_override_label,
1769                edge_override_from_cluster,
1770                edge_override_to_cluster,
1771            );
1772        }
1773    }
1774
1775    let mut cluster_rects_from_graph: std::collections::HashMap<String, Rect> =
1776        std::collections::HashMap::new();
1777    let mut extracted_cluster_rects: std::collections::HashMap<String, Rect> =
1778        std::collections::HashMap::new();
1779    let place_start = timing_enabled.then(std::time::Instant::now);
1780    place_graph(
1781        &g,
1782        (0.0, 0.0),
1783        true,
1784        &edge_id_by_key,
1785        &extracted_graphs,
1786        &subgraph_ids,
1787        &leaf_node_ids,
1788        title_total_margin,
1789        &mut base_pos,
1790        &mut leaf_rects,
1791        &mut cluster_rects_from_graph,
1792        &mut extracted_cluster_rects,
1793        &mut edge_override_points,
1794        &mut edge_override_label,
1795        &mut edge_override_from_cluster,
1796        &mut edge_override_to_cluster,
1797    );
1798    if let Some(s) = place_start {
1799        timings.place_graph = s.elapsed();
1800    }
1801
1802    let build_output_start = timing_enabled.then(std::time::Instant::now);
1803
1804    let mut extra_children: std::collections::HashMap<String, Vec<String>> =
1805        std::collections::HashMap::new();
1806    let labeled_edges: std::collections::HashSet<&str> = render_edges
1807        .iter()
1808        .filter(|e| edge_label_is_non_empty(e))
1809        .map(|e| e.id.as_str())
1810        .collect();
1811
1812    fn collect_extra_children(
1813        graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1814        edge_id_by_key: &std::collections::HashMap<String, String>,
1815        labeled_edges: &std::collections::HashSet<&str>,
1816        implicit_root: Option<&str>,
1817        out: &mut std::collections::HashMap<String, Vec<String>>,
1818    ) {
1819        for ek in graph.edge_keys() {
1820            let Some(edge_key) = ek.name.as_deref() else {
1821                continue;
1822            };
1823            let edge_id = edge_id_by_key
1824                .get(edge_key)
1825                .map(String::as_str)
1826                .unwrap_or(edge_key);
1827            if !labeled_edges.contains(edge_id) {
1828                continue;
1829            }
1830            // Mermaid's recursive cluster extractor removes the root cluster node from the
1831            // extracted graph. In that case, the "lowest common parent" of edges whose endpoints
1832            // belong to the extracted cluster becomes `None`, even though the label should still
1833            // participate in the extracted cluster's bounding box. Use `implicit_root` to map
1834            // those labels back to the extracted cluster id.
1835            let parent = lowest_common_parent(graph, &ek.v, &ek.w)
1836                .or_else(|| implicit_root.map(|s| s.to_string()));
1837            let Some(parent) = parent else {
1838                continue;
1839            };
1840            out.entry(parent)
1841                .or_default()
1842                .push(format!("edge-label::{edge_id}"));
1843        }
1844    }
1845
1846    collect_extra_children(
1847        &g,
1848        &edge_id_by_key,
1849        &labeled_edges,
1850        None,
1851        &mut extra_children,
1852    );
1853    for (cluster_id, cg) in &extracted_graphs {
1854        collect_extra_children(
1855            cg,
1856            &edge_id_by_key,
1857            &labeled_edges,
1858            Some(cluster_id.as_str()),
1859            &mut extra_children,
1860        );
1861    }
1862
1863    // Ensure Mermaid-style self-loop helper nodes participate in cluster bounding/packing.
1864    // These nodes are not part of the semantic `subgraph ... end` membership list, but are
1865    // parented into the same clusters as their base node.
1866    for id in &self_loop_label_node_ids {
1867        if let Some(p) = g.parent(id) {
1868            extra_children
1869                .entry(p.to_string())
1870                .or_default()
1871                .push(id.clone());
1872        }
1873    }
1874
1875    // Mermaid does not apply an extra post-layout packing step for disconnected subgraphs.
1876    // Keep the experimental packing logic behind a feature flag for debugging only.
1877    #[cfg(feature = "flowchart_root_pack")]
1878    {
1879        let subgraph_ids: std::collections::HashSet<&str> =
1880            model.subgraphs.iter().map(|s| s.id.as_str()).collect();
1881
1882        let mut subgraph_has_parent: std::collections::HashSet<&str> =
1883            std::collections::HashSet::new();
1884        for sg in &model.subgraphs {
1885            for child in &sg.nodes {
1886                if subgraph_ids.contains(child.as_str()) {
1887                    subgraph_has_parent.insert(child.as_str());
1888                }
1889            }
1890        }
1891
1892        fn collect_descendant_leaf_nodes<'a>(
1893            id: &'a str,
1894            subgraphs_by_id: &'a std::collections::HashMap<String, FlowSubgraph>,
1895            subgraph_ids: &std::collections::HashSet<&'a str>,
1896            out: &mut std::collections::HashSet<String>,
1897            visiting: &mut std::collections::HashSet<&'a str>,
1898        ) {
1899            if !visiting.insert(id) {
1900                return;
1901            }
1902            let Some(sg) = subgraphs_by_id.get(id) else {
1903                visiting.remove(id);
1904                return;
1905            };
1906            for member in &sg.nodes {
1907                if subgraph_ids.contains(member.as_str()) {
1908                    collect_descendant_leaf_nodes(
1909                        member,
1910                        subgraphs_by_id,
1911                        subgraph_ids,
1912                        out,
1913                        visiting,
1914                    );
1915                } else {
1916                    out.insert(member.clone());
1917                }
1918            }
1919            visiting.remove(id);
1920        }
1921
1922        fn collect_descendant_cluster_ids<'a>(
1923            id: &'a str,
1924            subgraphs_by_id: &'a std::collections::HashMap<String, FlowSubgraph>,
1925            subgraph_ids: &std::collections::HashSet<&'a str>,
1926            out: &mut std::collections::HashSet<String>,
1927            visiting: &mut std::collections::HashSet<&'a str>,
1928        ) {
1929            if !visiting.insert(id) {
1930                return;
1931            }
1932            let Some(sg) = subgraphs_by_id.get(id) else {
1933                visiting.remove(id);
1934                return;
1935            };
1936            out.insert(id.to_string());
1937            for member in &sg.nodes {
1938                if subgraph_ids.contains(member.as_str()) {
1939                    collect_descendant_cluster_ids(
1940                        member,
1941                        subgraphs_by_id,
1942                        subgraph_ids,
1943                        out,
1944                        visiting,
1945                    );
1946                }
1947            }
1948            visiting.remove(id);
1949        }
1950
1951        fn has_external_edges(
1952            leaves: &std::collections::HashSet<String>,
1953            edges: &[FlowEdge],
1954        ) -> bool {
1955            for e in edges {
1956                let in_from = leaves.contains(&e.from);
1957                let in_to = leaves.contains(&e.to);
1958                if in_from ^ in_to {
1959                    return true;
1960                }
1961            }
1962            false
1963        }
1964
1965        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1966        enum PackAxis {
1967            X,
1968            Y,
1969        }
1970
1971        let pack_axis = match diagram_direction.as_str() {
1972            "LR" | "RL" => PackAxis::Y,
1973            _ => PackAxis::X,
1974        };
1975
1976        let mut pack_rects: std::collections::HashMap<String, Rect> =
1977            std::collections::HashMap::new();
1978        let mut pack_visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
1979
1980        fn compute_pack_rect(
1981            id: &str,
1982            subgraphs_by_id: &std::collections::HashMap<String, FlowSubgraph>,
1983            leaf_rects: &std::collections::HashMap<String, Rect>,
1984            extra_children: &std::collections::HashMap<String, Vec<String>>,
1985            extracted_cluster_rects: &std::collections::HashMap<String, Rect>,
1986            pack_rects: &mut std::collections::HashMap<String, Rect>,
1987            pack_visiting: &mut std::collections::HashSet<String>,
1988            measurer: &dyn TextMeasurer,
1989            text_style: &TextStyle,
1990            title_wrapping_width: f64,
1991            wrap_mode: WrapMode,
1992            cluster_padding: f64,
1993            title_total_margin: f64,
1994            node_padding: f64,
1995        ) -> Option<Rect> {
1996            if let Some(r) = pack_rects.get(id).copied() {
1997                return Some(r);
1998            }
1999            if !pack_visiting.insert(id.to_string()) {
2000                return None;
2001            }
2002            if let Some(r) = extracted_cluster_rects.get(id).copied() {
2003                pack_visiting.remove(id);
2004                pack_rects.insert(id.to_string(), r);
2005                return Some(r);
2006            }
2007            let Some(sg) = subgraphs_by_id.get(id) else {
2008                pack_visiting.remove(id);
2009                return None;
2010            };
2011
2012            let mut content: Option<Rect> = None;
2013            for member in &sg.nodes {
2014                let member_rect = if let Some(r) = leaf_rects.get(member).copied() {
2015                    Some(r)
2016                } else if subgraphs_by_id.contains_key(member) {
2017                    compute_pack_rect(
2018                        member,
2019                        subgraphs_by_id,
2020                        leaf_rects,
2021                        extra_children,
2022                        extracted_cluster_rects,
2023                        pack_rects,
2024                        pack_visiting,
2025                        measurer,
2026                        text_style,
2027                        title_wrapping_width,
2028                        wrap_mode,
2029                        cluster_padding,
2030                        title_total_margin,
2031                        node_padding,
2032                    )
2033                } else {
2034                    None
2035                };
2036
2037                if let Some(r) = member_rect {
2038                    if let Some(ref mut cur) = content {
2039                        cur.union(r);
2040                    } else {
2041                        content = Some(r);
2042                    }
2043                }
2044            }
2045
2046            if let Some(extra) = extra_children.get(id) {
2047                for child in extra {
2048                    if let Some(r) = leaf_rects.get(child).copied() {
2049                        if let Some(ref mut cur) = content {
2050                            cur.union(r);
2051                        } else {
2052                            content = Some(r);
2053                        }
2054                    }
2055                }
2056            }
2057
2058            let label_type = sg.label_type.as_deref().unwrap_or("text");
2059            let title_width_limit = Some(title_wrapping_width);
2060            let title_metrics = flowchart_label_metrics_for_layout(
2061                measurer,
2062                &sg.title,
2063                label_type,
2064                text_style,
2065                title_width_limit,
2066                wrap_mode,
2067                effective_config,
2068                math_renderer,
2069            );
2070
2071            let mut rect = if let Some(r) = content {
2072                r
2073            } else {
2074                Rect::from_center(
2075                    0.0,
2076                    0.0,
2077                    title_metrics.width.max(1.0),
2078                    title_metrics.height.max(1.0),
2079                )
2080            };
2081
2082            rect.min_x -= cluster_padding;
2083            rect.max_x += cluster_padding;
2084            rect.min_y -= cluster_padding;
2085            rect.max_y += cluster_padding;
2086
2087            // Mermaid cluster "rect" rendering widens to fit the raw title bbox, plus a small
2088            // horizontal inset. Empirically (Mermaid@11.12.2 fixtures), this behaves like
2089            // `title_width + cluster_padding` when the title is wider than the content.
2090            let min_width = title_metrics.width.max(1.0) + cluster_padding;
2091            if rect.width() < min_width {
2092                let (cx, cy) = rect.center();
2093                rect = Rect::from_center(cx, cy, min_width, rect.height());
2094            }
2095
2096            if title_total_margin > 0.0 {
2097                let (cx, cy) = rect.center();
2098                rect = Rect::from_center(cx, cy, rect.width(), rect.height() + title_total_margin);
2099            }
2100
2101            let min_height = title_metrics.height.max(1.0) + title_total_margin;
2102            if rect.height() < min_height {
2103                let (cx, cy) = rect.center();
2104                rect = Rect::from_center(cx, cy, rect.width(), min_height);
2105            }
2106
2107            pack_visiting.remove(id);
2108            pack_rects.insert(id.to_string(), rect);
2109            Some(rect)
2110        }
2111
2112        struct PackItem {
2113            rect: Rect,
2114            members: Vec<String>,
2115            internal_edge_ids: Vec<String>,
2116            cluster_ids: Vec<String>,
2117        }
2118
2119        let mut items: Vec<PackItem> = Vec::new();
2120        for sg in &model.subgraphs {
2121            if subgraph_has_parent.contains(sg.id.as_str()) {
2122                continue;
2123            }
2124
2125            let mut leaves: std::collections::HashSet<String> = std::collections::HashSet::new();
2126            let mut visiting: std::collections::HashSet<&str> = std::collections::HashSet::new();
2127            collect_descendant_leaf_nodes(
2128                &sg.id,
2129                &subgraphs_by_id,
2130                &subgraph_ids,
2131                &mut leaves,
2132                &mut visiting,
2133            );
2134            if leaves.is_empty() {
2135                continue;
2136            }
2137
2138            // Treat cluster ids as part of the pack-membership boundary when detecting external
2139            // edges. Mermaid flowcharts allow edges to reference subgraph ids directly (cluster
2140            // nodes). If we only consider leaf nodes, edges like `X --> Y` would incorrectly mark
2141            // both top-level clusters as "isolated" and the packing step would separate them,
2142            // diverging from Mermaid's Dagre layout.
2143            let mut cluster_ids_set: std::collections::HashSet<String> =
2144                std::collections::HashSet::new();
2145            let mut cluster_visiting: std::collections::HashSet<&str> =
2146                std::collections::HashSet::new();
2147            collect_descendant_cluster_ids(
2148                &sg.id,
2149                &subgraphs_by_id,
2150                &subgraph_ids,
2151                &mut cluster_ids_set,
2152                &mut cluster_visiting,
2153            );
2154
2155            let mut membership_endpoints: std::collections::HashSet<String> =
2156                std::collections::HashSet::new();
2157            membership_endpoints.extend(leaves.iter().cloned());
2158            membership_endpoints.extend(cluster_ids_set.iter().cloned());
2159
2160            if has_external_edges(&membership_endpoints, &render_edges) {
2161                continue;
2162            }
2163
2164            let Some(rect) = compute_pack_rect(
2165                &sg.id,
2166                &subgraphs_by_id,
2167                &leaf_rects,
2168                &extra_children,
2169                &extracted_cluster_rects,
2170                &mut pack_rects,
2171                &mut pack_visiting,
2172                measurer,
2173                &text_style,
2174                cluster_title_wrapping_width,
2175                node_wrap_mode,
2176                cluster_padding,
2177                title_total_margin,
2178                node_padding,
2179            ) else {
2180                continue;
2181            };
2182
2183            let mut members = leaves.iter().cloned().collect::<Vec<_>>();
2184            if let Some(extra) = extra_children.get(&sg.id) {
2185                members.extend(extra.iter().cloned());
2186            }
2187
2188            // Ensure internal labeled edge nodes participate in translation.
2189            let mut internal_edge_ids: Vec<String> = Vec::new();
2190            for e in &render_edges {
2191                if leaves.contains(&e.from) && leaves.contains(&e.to) {
2192                    internal_edge_ids.push(e.id.clone());
2193                    if edge_label_is_non_empty(e) {
2194                        members.push(edge_label_leaf_id(e));
2195                    }
2196                }
2197            }
2198
2199            let mut cluster_ids = cluster_ids_set.into_iter().collect::<Vec<_>>();
2200            cluster_ids.sort();
2201
2202            items.push(PackItem {
2203                rect,
2204                members,
2205                internal_edge_ids,
2206                cluster_ids,
2207            });
2208        }
2209
2210        if !items.is_empty() {
2211            items.sort_by(|a, b| match pack_axis {
2212                PackAxis::X => a.rect.min_x.total_cmp(&b.rect.min_x),
2213                PackAxis::Y => a.rect.min_y.total_cmp(&b.rect.min_y),
2214            });
2215
2216            let mut cursor = match pack_axis {
2217                PackAxis::X => items.first().unwrap().rect.min_x,
2218                PackAxis::Y => items.first().unwrap().rect.min_y,
2219            };
2220
2221            for item in items {
2222                let (cx, cy) = item.rect.center();
2223                let desired_center = match pack_axis {
2224                    PackAxis::X => cursor + item.rect.width() / 2.0,
2225                    PackAxis::Y => cursor + item.rect.height() / 2.0,
2226                };
2227                let (dx, dy) = match pack_axis {
2228                    PackAxis::X => (desired_center - cx, 0.0),
2229                    PackAxis::Y => (0.0, desired_center - cy),
2230                };
2231
2232                if dx.abs() > 1e-6 || dy.abs() > 1e-6 {
2233                    for id in &item.members {
2234                        if let Some((x, y)) = base_pos.get_mut(id) {
2235                            *x += dx;
2236                            *y += dy;
2237                        }
2238                        if let Some(r) = leaf_rects.get_mut(id) {
2239                            r.translate(dx, dy);
2240                        }
2241                    }
2242                    for cid in &item.cluster_ids {
2243                        if let Some(r) = extracted_cluster_rects.get_mut(cid) {
2244                            r.translate(dx, dy);
2245                        }
2246                        if let Some(r) = cluster_rects_from_graph.get_mut(cid) {
2247                            r.translate(dx, dy);
2248                        }
2249                    }
2250                    for edge_id in &item.internal_edge_ids {
2251                        edge_packed_shift.insert(edge_id.clone(), (dx, dy));
2252                    }
2253                }
2254
2255                cursor += match pack_axis {
2256                    PackAxis::X => item.rect.width() + nodesep,
2257                    PackAxis::Y => item.rect.height() + nodesep,
2258                };
2259            }
2260        }
2261    }
2262
2263    let mut out_nodes: Vec<LayoutNode> = Vec::new();
2264    for n in &model.nodes {
2265        if subgraph_ids.contains(n.id.as_str()) {
2266            continue;
2267        }
2268        let (x, y) = base_pos
2269            .get(&n.id)
2270            .copied()
2271            .ok_or_else(|| Error::InvalidModel {
2272                message: format!("missing positioned node {}", n.id),
2273            })?;
2274        let (width, height) = leaf_rects
2275            .get(&n.id)
2276            .map(|r| (r.width(), r.height()))
2277            .ok_or_else(|| Error::InvalidModel {
2278                message: format!("missing sized node {}", n.id),
2279            })?;
2280        out_nodes.push(LayoutNode {
2281            id: n.id.clone(),
2282            x,
2283            y,
2284            width,
2285            height,
2286            is_cluster: false,
2287            label_width: leaf_label_metrics_by_id.get(&n.id).map(|v| v.0),
2288            label_height: leaf_label_metrics_by_id.get(&n.id).map(|v| v.1),
2289        });
2290    }
2291    for id in &empty_subgraph_ids {
2292        let (x, y) = base_pos
2293            .get(id)
2294            .copied()
2295            .ok_or_else(|| Error::InvalidModel {
2296                message: format!("missing positioned node {id}"),
2297            })?;
2298        let (width, height) = leaf_rects
2299            .get(id)
2300            .map(|r| (r.width(), r.height()))
2301            .ok_or_else(|| Error::InvalidModel {
2302                message: format!("missing sized node {id}"),
2303            })?;
2304        out_nodes.push(LayoutNode {
2305            id: id.clone(),
2306            x,
2307            y,
2308            width,
2309            height,
2310            is_cluster: false,
2311            label_width: leaf_label_metrics_by_id.get(id).map(|v| v.0),
2312            label_height: leaf_label_metrics_by_id.get(id).map(|v| v.1),
2313        });
2314    }
2315    for id in &self_loop_label_node_ids {
2316        let (x, y) = base_pos
2317            .get(id)
2318            .copied()
2319            .ok_or_else(|| Error::InvalidModel {
2320                message: format!("missing positioned node {id}"),
2321            })?;
2322        let (width, height) = leaf_rects
2323            .get(id)
2324            .map(|r| (r.width(), r.height()))
2325            .ok_or_else(|| Error::InvalidModel {
2326                message: format!("missing sized node {id}"),
2327            })?;
2328        out_nodes.push(LayoutNode {
2329            id: id.clone(),
2330            x,
2331            y,
2332            width,
2333            height,
2334            is_cluster: false,
2335            label_width: None,
2336            label_height: None,
2337        });
2338    }
2339
2340    let mut clusters: Vec<LayoutCluster> = Vec::new();
2341
2342    let mut cluster_rects: std::collections::HashMap<String, Rect> =
2343        std::collections::HashMap::new();
2344    let mut cluster_base_widths: std::collections::HashMap<String, f64> =
2345        std::collections::HashMap::new();
2346    let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
2347
2348    fn compute_cluster_rect(
2349        id: &str,
2350        subgraphs_by_id: &std::collections::HashMap<String, FlowSubgraph>,
2351        leaf_rects: &std::collections::HashMap<String, Rect>,
2352        extra_children: &std::collections::HashMap<String, Vec<String>>,
2353        cluster_rects: &mut std::collections::HashMap<String, Rect>,
2354        cluster_base_widths: &mut std::collections::HashMap<String, f64>,
2355        visiting: &mut std::collections::HashSet<String>,
2356        measurer: &dyn TextMeasurer,
2357        text_style: &TextStyle,
2358        title_wrapping_width: f64,
2359        wrap_mode: WrapMode,
2360        config: &MermaidConfig,
2361        math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
2362        cluster_padding: f64,
2363        title_total_margin: f64,
2364        _node_padding: f64,
2365    ) -> Result<(Rect, f64)> {
2366        if let Some(r) = cluster_rects.get(id).copied() {
2367            let base_width = cluster_base_widths
2368                .get(id)
2369                .copied()
2370                .unwrap_or_else(|| r.width());
2371            return Ok((r, base_width));
2372        }
2373        if !visiting.insert(id.to_string()) {
2374            return Err(Error::InvalidModel {
2375                message: format!("cycle in subgraph membership involving {id}"),
2376            });
2377        }
2378
2379        let Some(sg) = subgraphs_by_id.get(id) else {
2380            return Err(Error::InvalidModel {
2381                message: format!("missing subgraph definition for {id}"),
2382            });
2383        };
2384
2385        let mut content: Option<Rect> = None;
2386        for member in &sg.nodes {
2387            let member_rect = if let Some(r) = leaf_rects.get(member).copied() {
2388                Some(r)
2389            } else if subgraphs_by_id.contains_key(member) {
2390                Some(
2391                    compute_cluster_rect(
2392                        member,
2393                        subgraphs_by_id,
2394                        leaf_rects,
2395                        extra_children,
2396                        cluster_rects,
2397                        cluster_base_widths,
2398                        visiting,
2399                        measurer,
2400                        text_style,
2401                        title_wrapping_width,
2402                        wrap_mode,
2403                        config,
2404                        math_renderer,
2405                        cluster_padding,
2406                        title_total_margin,
2407                        _node_padding,
2408                    )?
2409                    .0,
2410                )
2411            } else {
2412                None
2413            };
2414
2415            if let Some(r) = member_rect {
2416                if let Some(ref mut cur) = content {
2417                    cur.union(r);
2418                } else {
2419                    content = Some(r);
2420                }
2421            }
2422        }
2423
2424        if let Some(extra) = extra_children.get(id) {
2425            for child in extra {
2426                if let Some(r) = leaf_rects.get(child).copied() {
2427                    if let Some(ref mut cur) = content {
2428                        cur.union(r);
2429                    } else {
2430                        content = Some(r);
2431                    }
2432                }
2433            }
2434        }
2435
2436        let label_type = sg.label_type.as_deref().unwrap_or("text");
2437        let title_width_limit = Some(title_wrapping_width);
2438        let title_metrics = flowchart_label_metrics_for_layout(
2439            measurer,
2440            &sg.title,
2441            label_type,
2442            text_style,
2443            title_width_limit,
2444            wrap_mode,
2445            config,
2446            math_renderer,
2447        );
2448        let mut rect = if let Some(r) = content {
2449            r
2450        } else {
2451            Rect::from_center(
2452                0.0,
2453                0.0,
2454                title_metrics.width.max(1.0),
2455                title_metrics.height.max(1.0),
2456            )
2457        };
2458
2459        // Expand to provide the cluster's internal padding.
2460        rect.pad(cluster_padding);
2461
2462        // Mermaid computes `node.diff` using the pre-widened layout node width, then may widen the
2463        // rect to fit the label bbox during rendering.
2464        let base_width = rect.width();
2465
2466        // Mermaid cluster "rect" rendering widens to fit the raw title bbox, plus a small
2467        // horizontal inset. Empirically (Mermaid@11.12.2 fixtures), this behaves like
2468        // `title_width + cluster_padding` when the title is wider than the content.
2469        let min_width = title_metrics.width.max(1.0) + cluster_padding;
2470        if rect.width() < min_width {
2471            let (cx, cy) = rect.center();
2472            rect = Rect::from_center(cx, cy, min_width, rect.height());
2473        }
2474
2475        // Extend height to reserve space for subgraph title margins (Mermaid does this after layout).
2476        if title_total_margin > 0.0 {
2477            let (cx, cy) = rect.center();
2478            rect = Rect::from_center(cx, cy, rect.width(), rect.height() + title_total_margin);
2479        }
2480
2481        // Keep the cluster tall enough to accommodate the title bbox if needed.
2482        let min_height = title_metrics.height.max(1.0) + title_total_margin;
2483        if rect.height() < min_height {
2484            let (cx, cy) = rect.center();
2485            rect = Rect::from_center(cx, cy, rect.width(), min_height);
2486        }
2487
2488        visiting.remove(id);
2489        cluster_rects.insert(id.to_string(), rect);
2490        cluster_base_widths.insert(id.to_string(), base_width);
2491        Ok((rect, base_width))
2492    }
2493
2494    for sg in &model.subgraphs {
2495        fn adjust_cluster_rect_for_title(
2496            mut rect: Rect,
2497            title: &str,
2498            label_type: &str,
2499            measurer: &dyn TextMeasurer,
2500            text_style: &TextStyle,
2501            title_wrapping_width: f64,
2502            wrap_mode: WrapMode,
2503            config: &MermaidConfig,
2504            math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
2505            title_total_margin: f64,
2506            cluster_padding: f64,
2507            add_title_total_margin: bool,
2508        ) -> Rect {
2509            let title_width_limit = Some(title_wrapping_width);
2510            let title_metrics = flowchart_label_metrics_for_layout(
2511                measurer,
2512                title,
2513                label_type,
2514                text_style,
2515                title_width_limit,
2516                wrap_mode,
2517                config,
2518                math_renderer,
2519            );
2520            let title_w = title_metrics.width.max(1.0);
2521            let title_h = title_metrics.height.max(1.0);
2522
2523            // Mermaid cluster "rect" widens to fit the raw title bbox (no added padding),
2524            // even when the cluster bounds come from Dagre border nodes.
2525            let min_w = title_w + cluster_padding;
2526            if rect.width() < min_w {
2527                let (cx, cy) = rect.center();
2528                rect = Rect::from_center(cx, cy, min_w, rect.height());
2529            }
2530
2531            // Mermaid adds `subGraphTitleTotalMargin` to cluster height after layout.
2532            if add_title_total_margin && title_total_margin > 0.0 {
2533                let (cx, cy) = rect.center();
2534                rect = Rect::from_center(cx, cy, rect.width(), rect.height() + title_total_margin);
2535            }
2536
2537            // Keep the cluster tall enough for the title bbox (including title margins).
2538            let min_h = title_h + title_total_margin;
2539            if rect.height() < min_h {
2540                let (cx, cy) = rect.center();
2541                rect = Rect::from_center(cx, cy, rect.width(), min_h);
2542            }
2543
2544            rect
2545        }
2546
2547        if sg.nodes.is_empty() {
2548            continue;
2549        }
2550
2551        let (rect, base_width) = if extracted_graphs.contains_key(&sg.id) {
2552            // For extracted (recursive) clusters, match Mermaid's `updateNodeBounds(...)` intent by
2553            // taking the rendered child-graph content bbox (including border nodes) as the cluster
2554            // node's bounds.
2555            let rect = extracted_cluster_rects
2556                .get(&sg.id)
2557                .copied()
2558                .unwrap_or_else(|| {
2559                    compute_cluster_rect(
2560                        &sg.id,
2561                        &subgraphs_by_id,
2562                        &leaf_rects,
2563                        &extra_children,
2564                        &mut cluster_rects,
2565                        &mut cluster_base_widths,
2566                        &mut visiting,
2567                        measurer,
2568                        &text_style,
2569                        cluster_title_wrapping_width,
2570                        cluster_wrap_mode,
2571                        effective_config,
2572                        math_renderer,
2573                        cluster_padding,
2574                        title_total_margin,
2575                        node_padding,
2576                    )
2577                    .map(|v| v.0)
2578                    .unwrap_or_else(|_| Rect::from_center(0.0, 0.0, 1.0, 1.0))
2579                });
2580            let base_width = rect.width();
2581            let rect = adjust_cluster_rect_for_title(
2582                rect,
2583                &sg.title,
2584                sg.label_type.as_deref().unwrap_or("text"),
2585                measurer,
2586                &text_style,
2587                cluster_title_wrapping_width,
2588                cluster_wrap_mode,
2589                effective_config,
2590                math_renderer,
2591                title_total_margin,
2592                cluster_padding,
2593                false,
2594            );
2595            (rect, base_width)
2596        } else if let Some(r) = cluster_rects_from_graph.get(&sg.id).copied() {
2597            let base_width = r.width();
2598            let rect = adjust_cluster_rect_for_title(
2599                r,
2600                &sg.title,
2601                sg.label_type.as_deref().unwrap_or("text"),
2602                measurer,
2603                &text_style,
2604                cluster_title_wrapping_width,
2605                cluster_wrap_mode,
2606                effective_config,
2607                math_renderer,
2608                title_total_margin,
2609                cluster_padding,
2610                true,
2611            );
2612            (rect, base_width)
2613        } else {
2614            compute_cluster_rect(
2615                &sg.id,
2616                &subgraphs_by_id,
2617                &leaf_rects,
2618                &extra_children,
2619                &mut cluster_rects,
2620                &mut cluster_base_widths,
2621                &mut visiting,
2622                measurer,
2623                &text_style,
2624                cluster_title_wrapping_width,
2625                cluster_wrap_mode,
2626                effective_config,
2627                math_renderer,
2628                cluster_padding,
2629                title_total_margin,
2630                node_padding,
2631            )?
2632        };
2633        let (cx, cy) = rect.center();
2634
2635        let label_type = sg.label_type.as_deref().unwrap_or("text");
2636        let title_width_limit = Some(cluster_title_wrapping_width);
2637        let mut title_metrics = flowchart_label_metrics_for_layout(
2638            measurer,
2639            &sg.title,
2640            label_type,
2641            &text_style,
2642            title_width_limit,
2643            cluster_wrap_mode,
2644            effective_config,
2645            math_renderer,
2646        );
2647        if cluster_wrap_mode == crate::text::WrapMode::SvgLike && label_type != "markdown" {
2648            // Mermaid's flowchart cluster titles rendered as plain SVG `<text>` are measured via
2649            // `getComputedTextLength()` rather than `getBBox().width` (the latter includes ASCII
2650            // overhang and differs for short tokens like `One` in upstream docs fixtures).
2651            let plain = crate::flowchart::flowchart_label_plain_text_for_layout(
2652                &sg.title, label_type, false,
2653            );
2654            let mut w: f64 = 0.0;
2655            for line in plain.lines() {
2656                w = w.max(
2657                    measurer.measure_svg_text_computed_length_px(line.trim_end(), &text_style),
2658                );
2659            }
2660            title_metrics.width = crate::text::round_to_1_64_px(w);
2661        } else if cluster_wrap_mode == crate::text::WrapMode::SvgLike && label_type == "markdown" {
2662            // Cluster titles with markdown emphasis are rendered as SVG `<text>/<tspan>` runs and
2663            // measured via browser `getBBox()` in upstream Mermaid. For italic `<em>` titles, the
2664            // 1/64px-snapped markdown width can be just enough to shift the left-aligned label
2665            // transform across a strict-XML rounding boundary; use a tighter lattice width probe.
2666            let has_emphasis = crate::text::mermaid_markdown_to_wrapped_word_lines(
2667                measurer,
2668                &sg.title,
2669                &text_style,
2670                title_width_limit,
2671                cluster_wrap_mode,
2672            )
2673            .iter()
2674            .any(|line| {
2675                line.iter()
2676                    .any(|(_, ty)| *ty == crate::text::MermaidMarkdownWordType::Em)
2677            });
2678            if has_emphasis {
2679                title_metrics.width = crate::text::measure_markdown_svg_like_precise_width_px(
2680                    measurer,
2681                    &sg.title,
2682                    &text_style,
2683                    title_width_limit,
2684                );
2685            }
2686        }
2687        let title_label = LayoutLabel {
2688            x: cx,
2689            y: cy - rect.height() / 2.0 + title_margin_top + title_metrics.height / 2.0,
2690            width: title_metrics.width,
2691            height: title_metrics.height,
2692        };
2693
2694        // `dagre-wrapper/clusters.js` (shape `rect`) sets `padding = 0 * node.padding`.
2695        // The cluster label is positioned at `node.x - bbox.width/2`, and `node.diff` is:
2696        // - `(bbox.width - node.width)/2 - node.padding/2` when the box widens to fit the title
2697        // - otherwise `-node.padding/2`.
2698        let title_w = title_metrics.width.max(1.0);
2699        let diff = if base_width <= title_w {
2700            (title_w - base_width) / 2.0 - cluster_padding / 2.0
2701        } else {
2702            -cluster_padding / 2.0
2703        };
2704        let offset_y = title_metrics.height - cluster_padding / 2.0;
2705
2706        let effective_dir = effective_dir_by_id
2707            .get(&sg.id)
2708            .cloned()
2709            .unwrap_or_else(|| effective_cluster_dir(sg, &diagram_direction, inherit_dir));
2710
2711        clusters.push(LayoutCluster {
2712            id: sg.id.clone(),
2713            x: cx,
2714            y: cy,
2715            width: rect.width(),
2716            height: rect.height(),
2717            diff,
2718            offset_y,
2719            title: sg.title.clone(),
2720            title_label,
2721            requested_dir: sg.dir.as_ref().map(|s| normalize_dir(s)),
2722            effective_dir,
2723            padding: cluster_padding,
2724            title_margin_top,
2725            title_margin_bottom,
2726        });
2727
2728        out_nodes.push(LayoutNode {
2729            id: sg.id.clone(),
2730            x: cx,
2731            // Mermaid does not shift pure cluster nodes by `subGraphTitleTotalMargin / 2`.
2732            y: cy,
2733            width: rect.width(),
2734            height: rect.height(),
2735            is_cluster: true,
2736            label_width: None,
2737            label_height: None,
2738        });
2739    }
2740    clusters.sort_by(|a, b| a.id.cmp(&b.id));
2741
2742    let mut out_edges: Vec<LayoutEdge> = Vec::new();
2743    for e in &render_edges {
2744        let (dx, dy) = edge_packed_shift.get(&e.id).copied().unwrap_or((0.0, 0.0));
2745        let (
2746            mut points,
2747            mut label_pos,
2748            label_pos_already_shifted,
2749            mut from_cluster,
2750            mut to_cluster,
2751        ) = if let Some(points) = edge_override_points.get(&e.id) {
2752            let from_cluster = edge_override_from_cluster
2753                .get(&e.id)
2754                .cloned()
2755                .unwrap_or(None);
2756            let to_cluster = edge_override_to_cluster.get(&e.id).cloned().unwrap_or(None);
2757            (
2758                points.clone(),
2759                edge_override_label.get(&e.id).cloned().unwrap_or(None),
2760                false,
2761                from_cluster,
2762                to_cluster,
2763            )
2764        } else {
2765            let (v, w) = edge_endpoints_by_id
2766                .get(&e.id)
2767                .cloned()
2768                .unwrap_or_else(|| (e.from.clone(), e.to.clone()));
2769            let edge_key = edge_key_by_id
2770                .get(&e.id)
2771                .map(String::as_str)
2772                .unwrap_or(e.id.as_str());
2773            let Some(label) = g.edge(&v, &w, Some(edge_key)) else {
2774                return Err(Error::InvalidModel {
2775                    message: format!("missing layout edge {}", e.id),
2776                });
2777            };
2778            let from_cluster = label
2779                .extras
2780                .get("fromCluster")
2781                .and_then(|v| v.as_str().map(|s| s.to_string()));
2782            let to_cluster = label
2783                .extras
2784                .get("toCluster")
2785                .and_then(|v| v.as_str().map(|s| s.to_string()));
2786            let points = label
2787                .points
2788                .iter()
2789                .map(|p| LayoutPoint { x: p.x, y: p.y })
2790                .collect::<Vec<_>>();
2791            let label_pos = match (label.x, label.y) {
2792                (Some(x), Some(y)) if label.width > 0.0 || label.height > 0.0 => {
2793                    Some(LayoutLabel {
2794                        x,
2795                        y,
2796                        width: label.width,
2797                        height: label.height,
2798                    })
2799                }
2800                _ => None,
2801            };
2802            (points, label_pos, false, from_cluster, to_cluster)
2803        };
2804
2805        // Match Mermaid's dagre adapter: self-loop special edges on group nodes are annotated with
2806        // `fromCluster` / `toCluster` so downstream renderers can clip routes to the cluster
2807        // boundary.
2808        if subgraph_ids.contains(e.from.as_str()) && e.id.ends_with("-cyclic-special-1") {
2809            from_cluster = Some(e.from.clone());
2810        }
2811        if subgraph_ids.contains(e.to.as_str()) && e.id.ends_with("-cyclic-special-2") {
2812            to_cluster = Some(e.to.clone());
2813        }
2814
2815        if dx.abs() > 1e-6 || dy.abs() > 1e-6 {
2816            for p in &mut points {
2817                p.x += dx;
2818                p.y += dy;
2819            }
2820            if !label_pos_already_shifted {
2821                if let Some(l) = label_pos.as_mut() {
2822                    l.x += dx;
2823                    l.y += dy;
2824                }
2825            }
2826        }
2827        out_edges.push(LayoutEdge {
2828            id: e.id.clone(),
2829            from: e.from.clone(),
2830            to: e.to.clone(),
2831            from_cluster,
2832            to_cluster,
2833            points,
2834            label: label_pos,
2835            start_label_left: None,
2836            start_label_right: None,
2837            end_label_left: None,
2838            end_label_right: None,
2839            start_marker: None,
2840            end_marker: None,
2841            stroke_dasharray: None,
2842        });
2843    }
2844
2845    // Mermaid's flowchart renderer uses shape-specific intersection functions for edge endpoints
2846    // (e.g. diamond nodes). Our Dagre-ish layout currently treats all nodes as rectangles, so the
2847    // first/last points can land on the bounding box rather than the actual polygon boundary.
2848    //
2849    // Adjust the first/last edge points to match Mermaid's shape intersection behavior for the
2850    // shapes that materially differ from rectangles.
2851    let mut node_shape_by_id: HashMap<&str, &str> = HashMap::new();
2852    for n in &model.nodes {
2853        if let Some(s) = n.layout_shape.as_deref() {
2854            node_shape_by_id.insert(n.id.as_str(), s);
2855        }
2856    }
2857    let mut layout_node_by_id: HashMap<&str, &LayoutNode> = HashMap::new();
2858    for n in &out_nodes {
2859        layout_node_by_id.insert(n.id.as_str(), n);
2860    }
2861
2862    fn diamond_intersection(node: &LayoutNode, toward: &LayoutPoint) -> Option<LayoutPoint> {
2863        let vx = toward.x - node.x;
2864        let vy = toward.y - node.y;
2865        if !(vx.is_finite() && vy.is_finite()) {
2866            return None;
2867        }
2868        if vx.abs() <= 1e-12 && vy.abs() <= 1e-12 {
2869            return None;
2870        }
2871        let hw = (node.width / 2.0).max(1e-9);
2872        let hh = (node.height / 2.0).max(1e-9);
2873        let denom = vx.abs() / hw + vy.abs() / hh;
2874        if !(denom.is_finite() && denom > 0.0) {
2875            return None;
2876        }
2877        let t = 1.0 / denom;
2878        Some(LayoutPoint {
2879            x: node.x + vx * t,
2880            y: node.y + vy * t,
2881        })
2882    }
2883
2884    for e in &mut out_edges {
2885        if e.points.len() < 2 {
2886            continue;
2887        }
2888
2889        if let Some(node) = layout_node_by_id.get(e.from.as_str()) {
2890            if !node.is_cluster {
2891                let shape = node_shape_by_id
2892                    .get(e.from.as_str())
2893                    .copied()
2894                    .unwrap_or("squareRect");
2895                if matches!(shape, "diamond" | "question" | "diam") {
2896                    if let Some(p) = diamond_intersection(node, &e.points[1]) {
2897                        e.points[0] = p;
2898                    }
2899                }
2900            }
2901        }
2902        if let Some(node) = layout_node_by_id.get(e.to.as_str()) {
2903            if !node.is_cluster {
2904                let shape = node_shape_by_id
2905                    .get(e.to.as_str())
2906                    .copied()
2907                    .unwrap_or("squareRect");
2908                if matches!(shape, "diamond" | "question" | "diam") {
2909                    let n = e.points.len();
2910                    if let Some(p) = diamond_intersection(node, &e.points[n - 2]) {
2911                        e.points[n - 1] = p;
2912                    }
2913                }
2914            }
2915        }
2916    }
2917
2918    let bounds = compute_bounds(&out_nodes, &out_edges);
2919
2920    if let Some(s) = build_output_start {
2921        timings.build_output = s.elapsed();
2922    }
2923    if let Some(s) = total_start {
2924        timings.total = s.elapsed();
2925        let dagre_overhead = timings
2926            .layout_recursive
2927            .checked_sub(timings.dagre_total)
2928            .unwrap_or_default();
2929        eprintln!(
2930            "[layout-timing] diagram=flowchart-v2 total={:?} deserialize={:?} expand_self_loops={:?} build_graph={:?} extract_clusters={:?} dom_order={:?} layout_recursive={:?} dagre_calls={} dagre_total={:?} dagre_overhead={:?} place_graph={:?} build_output={:?}",
2931            timings.total,
2932            timings.deserialize,
2933            timings.expand_self_loops,
2934            timings.build_graph,
2935            timings.extract_clusters,
2936            timings.dom_order,
2937            timings.layout_recursive,
2938            timings.dagre_calls,
2939            timings.dagre_total,
2940            dagre_overhead,
2941            timings.place_graph,
2942            timings.build_output,
2943        );
2944    }
2945
2946    Ok(FlowchartV2Layout {
2947        nodes: out_nodes,
2948        edges: out_edges,
2949        clusters,
2950        bounds,
2951        dom_node_order_by_root,
2952    })
2953}