Skip to main content

merman_render/flowchart/
layout.rs

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