Skip to main content

merman_render/
class.rs

1use crate::entities::decode_entities_minimal;
2use crate::model::{
3    Bounds, ClassDiagramV2Layout, ClassNodeRowMetrics, LayoutCluster, LayoutEdge, LayoutLabel,
4    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 indexmap::IndexMap;
11use rustc_hash::FxHashMap;
12use serde_json::Value;
13use std::collections::{BTreeMap, HashMap, HashSet};
14use std::sync::Arc;
15
16type ClassDiagramModel = merman_core::models::class_diagram::ClassDiagram;
17type ClassNode = merman_core::models::class_diagram::ClassNode;
18
19fn json_f64(v: &Value) -> Option<f64> {
20    v.as_f64()
21        .or_else(|| v.as_i64().map(|n| n as f64))
22        .or_else(|| v.as_u64().map(|n| n as f64))
23}
24
25fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
26    let mut cur = cfg;
27    for key in path {
28        cur = cur.get(*key)?;
29    }
30    json_f64(cur)
31}
32
33fn config_bool(cfg: &Value, path: &[&str]) -> Option<bool> {
34    let mut cur = cfg;
35    for key in path {
36        cur = cur.get(*key)?;
37    }
38    cur.as_bool()
39}
40
41fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
42    let mut cur = cfg;
43    for key in path {
44        cur = cur.get(*key)?;
45    }
46    cur.as_str().map(|s| s.to_string())
47}
48
49fn normalize_dir(direction: &str) -> String {
50    match direction.trim().to_uppercase().as_str() {
51        "TB" | "TD" => "TB".to_string(),
52        "BT" => "BT".to_string(),
53        "LR" => "LR".to_string(),
54        "RL" => "RL".to_string(),
55        other => other.to_string(),
56    }
57}
58
59fn rank_dir_from(direction: &str) -> RankDir {
60    match normalize_dir(direction).as_str() {
61        "TB" => RankDir::TB,
62        "BT" => RankDir::BT,
63        "LR" => RankDir::LR,
64        "RL" => RankDir::RL,
65        _ => RankDir::TB,
66    }
67}
68
69type Rect = merman_core::geom::Box2;
70
71struct PreparedGraph {
72    graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
73    extracted: BTreeMap<String, PreparedGraph>,
74    prefer_dagreish_disconnected: bool,
75}
76
77fn extract_descendants(
78    graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
79    id: &str,
80    out: &mut Vec<String>,
81) {
82    for child in graph.children(id) {
83        out.push(child.to_string());
84        extract_descendants(graph, child, out);
85    }
86}
87
88fn is_descendant(descendants: &HashMap<String, HashSet<String>>, id: &str, ancestor: &str) -> bool {
89    descendants
90        .get(ancestor)
91        .is_some_and(|set| set.contains(id))
92}
93
94fn prepare_graph(
95    mut graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
96    depth: usize,
97    prefer_dagreish_disconnected: bool,
98) -> Result<PreparedGraph> {
99    if depth > 10 {
100        return Ok(PreparedGraph {
101            graph,
102            extracted: BTreeMap::new(),
103            prefer_dagreish_disconnected,
104        });
105    }
106
107    // Mermaid's dagre-wrapper performs a pre-pass that extracts clusters *without* external
108    // connections into their own subgraphs, toggles their rankdir (TB <-> LR), and renders them
109    // recursively to obtain concrete cluster geometry before laying out the parent graph.
110    //
111    // Reference: Mermaid@11.12.2 `mermaid-graphlib.js` extractor + `recursiveRender`:
112    // - eligible cluster: has children, and no edge crosses its descendant boundary
113    // - extracted subgraph gets `rankdir = parent.rankdir === 'TB' ? 'LR' : 'TB'`
114    // - subgraph rank spacing uses `ranksep = parent.ranksep + 25`
115    // - margins are fixed at 8
116
117    let cluster_ids: Vec<String> = graph
118        .node_ids()
119        .into_iter()
120        .filter(|id| !graph.children(id).is_empty())
121        .collect();
122
123    let mut descendants: HashMap<String, HashSet<String>> = HashMap::new();
124    for id in &cluster_ids {
125        let mut vec: Vec<String> = Vec::new();
126        extract_descendants(&graph, id, &mut vec);
127        descendants.insert(id.clone(), vec.into_iter().collect());
128    }
129
130    let mut external: HashMap<String, bool> =
131        cluster_ids.iter().map(|id| (id.clone(), false)).collect();
132    for id in &cluster_ids {
133        for e in graph.edge_keys() {
134            // Mermaid's `edgeInCluster` treats edges incident on the cluster node itself as
135            // non-descendant edges. Class diagrams do not normally connect edges to namespaces,
136            // but keep the guard to mirror upstream behavior.
137            if e.v == *id || e.w == *id {
138                continue;
139            }
140            let d1 = is_descendant(&descendants, &e.v, id);
141            let d2 = is_descendant(&descendants, &e.w, id);
142            if d1 ^ d2 {
143                external.insert(id.clone(), true);
144                break;
145            }
146        }
147    }
148
149    let mut extracted: BTreeMap<String, PreparedGraph> = BTreeMap::new();
150    let candidate_clusters: Vec<String> = graph
151        .node_ids()
152        .into_iter()
153        .filter(|id| !graph.children(id).is_empty() && !external.get(id).copied().unwrap_or(false))
154        .collect();
155
156    for cluster_id in candidate_clusters {
157        if graph.children(&cluster_id).is_empty() {
158            continue;
159        }
160        let parent_dir = graph.graph().rankdir;
161        let dir = if parent_dir == RankDir::TB {
162            RankDir::LR
163        } else {
164            RankDir::TB
165        };
166
167        let nodesep = graph.graph().nodesep;
168        let ranksep = graph.graph().ranksep;
169
170        let mut subgraph = extract_cluster_graph(&cluster_id, &mut graph)?;
171        subgraph.graph_mut().rankdir = dir;
172        subgraph.graph_mut().nodesep = nodesep;
173        subgraph.graph_mut().ranksep = ranksep + 25.0;
174        subgraph.graph_mut().marginx = 8.0;
175        subgraph.graph_mut().marginy = 8.0;
176
177        let prepared = prepare_graph(subgraph, depth + 1, prefer_dagreish_disconnected)?;
178        extracted.insert(cluster_id, prepared);
179    }
180
181    Ok(PreparedGraph {
182        graph,
183        extracted,
184        prefer_dagreish_disconnected,
185    })
186}
187
188fn extract_cluster_graph(
189    cluster_id: &str,
190    graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
191) -> Result<Graph<NodeLabel, EdgeLabel, GraphLabel>> {
192    if graph.children(cluster_id).is_empty() {
193        return Err(Error::InvalidModel {
194            message: format!("cluster has no children: {cluster_id}"),
195        });
196    }
197
198    let mut descendants: Vec<String> = Vec::new();
199    extract_descendants(graph, cluster_id, &mut descendants);
200    descendants.sort();
201    descendants.dedup();
202
203    let moved_set: HashSet<String> = descendants.iter().cloned().collect();
204
205    let mut sub = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
206        directed: true,
207        multigraph: true,
208        compound: true,
209    });
210
211    // Preserve parent graph settings as a base.
212    sub.set_graph(graph.graph().clone());
213
214    for id in &descendants {
215        let Some(label) = graph.node(id).cloned() else {
216            continue;
217        };
218        sub.set_node(id.clone(), label);
219    }
220
221    for key in graph.edge_keys() {
222        if moved_set.contains(&key.v) && moved_set.contains(&key.w) {
223            if let Some(label) = graph.edge_by_key(&key).cloned() {
224                sub.set_edge_named(key.v.clone(), key.w.clone(), key.name.clone(), Some(label));
225            }
226        }
227    }
228
229    for id in &descendants {
230        let Some(parent) = graph.parent(id) else {
231            continue;
232        };
233        if moved_set.contains(parent) {
234            sub.set_parent(id.clone(), parent.to_string());
235        }
236    }
237
238    for id in &descendants {
239        let _ = graph.remove_node(id);
240    }
241
242    Ok(sub)
243}
244
245#[derive(Debug, Clone)]
246struct EdgeTerminalMetrics {
247    start_left: Option<(f64, f64)>,
248    start_right: Option<(f64, f64)>,
249    end_left: Option<(f64, f64)>,
250    end_right: Option<(f64, f64)>,
251    start_marker: f64,
252    end_marker: f64,
253}
254
255fn edge_terminal_metrics_from_extras(e: &EdgeLabel) -> EdgeTerminalMetrics {
256    let get_pair = |key: &str| -> Option<(f64, f64)> {
257        let obj = e.extras.get(key)?;
258        let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0);
259        let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0);
260        if w > 0.0 && h > 0.0 {
261            Some((w, h))
262        } else {
263            None
264        }
265    };
266    let start_marker = e
267        .extras
268        .get("startMarker")
269        .and_then(|v| v.as_f64())
270        .unwrap_or(0.0);
271    let end_marker = e
272        .extras
273        .get("endMarker")
274        .and_then(|v| v.as_f64())
275        .unwrap_or(0.0);
276    EdgeTerminalMetrics {
277        start_left: get_pair("startLeft"),
278        start_right: get_pair("startRight"),
279        end_left: get_pair("endLeft"),
280        end_right: get_pair("endRight"),
281        start_marker,
282        end_marker,
283    }
284}
285
286#[derive(Debug, Clone)]
287struct LayoutFragments {
288    nodes: IndexMap<String, LayoutNode>,
289    edges: Vec<(LayoutEdge, Option<EdgeTerminalMetrics>)>,
290}
291
292fn round_number(num: f64, precision: i32) -> f64 {
293    if !num.is_finite() {
294        return 0.0;
295    }
296    let factor = 10_f64.powi(precision);
297    (num * factor).round() / factor
298}
299
300fn distance(a: &LayoutPoint, b: Option<&LayoutPoint>) -> f64 {
301    let Some(b) = b else {
302        return 0.0;
303    };
304    let dx = a.x - b.x;
305    let dy = a.y - b.y;
306    (dx * dx + dy * dy).sqrt()
307}
308
309fn calculate_point(points: &[LayoutPoint], distance_to_traverse: f64) -> Option<LayoutPoint> {
310    if points.is_empty() {
311        return None;
312    }
313    let mut prev: Option<&LayoutPoint> = None;
314    let mut remaining = distance_to_traverse.max(0.0);
315    for p in points {
316        if let Some(prev_p) = prev {
317            let vector_distance = distance(p, Some(prev_p));
318            if vector_distance == 0.0 {
319                return Some(prev_p.clone());
320            }
321            if vector_distance < remaining {
322                remaining -= vector_distance;
323            } else {
324                let ratio = remaining / vector_distance;
325                if ratio <= 0.0 {
326                    return Some(prev_p.clone());
327                }
328                if ratio >= 1.0 {
329                    return Some(p.clone());
330                }
331                return Some(LayoutPoint {
332                    x: round_number((1.0 - ratio) * prev_p.x + ratio * p.x, 5),
333                    y: round_number((1.0 - ratio) * prev_p.y + ratio * p.y, 5),
334                });
335            }
336        }
337        prev = Some(p);
338    }
339    None
340}
341
342#[derive(Debug, Clone, Copy)]
343enum TerminalPos {
344    StartLeft,
345    StartRight,
346    EndLeft,
347    EndRight,
348}
349
350fn point_inside_rect(rect: Rect, x: f64, y: f64, eps: f64) -> bool {
351    x > rect.min_x() + eps
352        && x < rect.max_x() - eps
353        && y > rect.min_y() + eps
354        && y < rect.max_y() - eps
355}
356
357fn nudge_point_outside_rect(mut x: f64, mut y: f64, rect: Rect) -> (f64, f64) {
358    let eps = 0.01;
359    if !point_inside_rect(rect, x, y, eps) {
360        return (x, y);
361    }
362
363    let (cx, cy) = rect.center();
364    let mut dx = x - cx;
365    let mut dy = y - cy;
366    let len = (dx * dx + dy * dy).sqrt();
367    if len < 1e-9 {
368        dx = 1.0;
369        dy = 0.0;
370    } else {
371        dx /= len;
372        dy /= len;
373    }
374
375    let mut t_exit = f64::INFINITY;
376    if dx > 1e-9 {
377        t_exit = t_exit.min((rect.max_x() - x) / dx);
378    } else if dx < -1e-9 {
379        t_exit = t_exit.min((rect.min_x() - x) / dx);
380    }
381    if dy > 1e-9 {
382        t_exit = t_exit.min((rect.max_y() - y) / dy);
383    } else if dy < -1e-9 {
384        t_exit = t_exit.min((rect.min_y() - y) / dy);
385    }
386
387    if t_exit.is_finite() && t_exit >= 0.0 {
388        let margin = 0.5;
389        x += dx * (t_exit + margin);
390        y += dy * (t_exit + margin);
391    }
392
393    (x, y)
394}
395
396fn calc_terminal_label_position(
397    terminal_marker_size: f64,
398    position: TerminalPos,
399    points: &[LayoutPoint],
400) -> Option<(f64, f64)> {
401    if points.len() < 2 {
402        return None;
403    }
404
405    let mut pts = points.to_vec();
406    match position {
407        TerminalPos::StartLeft | TerminalPos::StartRight => {}
408        TerminalPos::EndLeft | TerminalPos::EndRight => pts.reverse(),
409    }
410
411    let distance_to_cardinality_point = 25.0 + terminal_marker_size;
412    let center = calculate_point(&pts, distance_to_cardinality_point)?;
413    let d = 10.0 + terminal_marker_size * 0.5;
414    let angle = (pts[0].y - center.y).atan2(pts[0].x - center.x);
415
416    let (x, y) = match position {
417        TerminalPos::StartLeft => {
418            let a = angle + std::f64::consts::PI;
419            (
420                a.sin() * d + (pts[0].x + center.x) / 2.0,
421                -a.cos() * d + (pts[0].y + center.y) / 2.0,
422            )
423        }
424        TerminalPos::StartRight => (
425            angle.sin() * d + (pts[0].x + center.x) / 2.0,
426            -angle.cos() * d + (pts[0].y + center.y) / 2.0,
427        ),
428        TerminalPos::EndLeft => (
429            angle.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
430            -angle.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
431        ),
432        TerminalPos::EndRight => {
433            let a = angle - std::f64::consts::PI;
434            (
435                a.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
436                -a.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
437            )
438        }
439    };
440    Some((x, y))
441}
442
443fn intersect_segment_with_rect(
444    p0: &LayoutPoint,
445    p1: &LayoutPoint,
446    rect: Rect,
447) -> Option<LayoutPoint> {
448    let dx = p1.x - p0.x;
449    let dy = p1.y - p0.y;
450    if dx == 0.0 && dy == 0.0 {
451        return None;
452    }
453
454    let mut candidates: Vec<(f64, LayoutPoint)> = Vec::new();
455    let eps = 1e-9;
456    let min_x = rect.min_x();
457    let max_x = rect.max_x();
458    let min_y = rect.min_y();
459    let max_y = rect.max_y();
460
461    if dx.abs() > eps {
462        for x_edge in [min_x, max_x] {
463            let t = (x_edge - p0.x) / dx;
464            if t < -eps || t > 1.0 + eps {
465                continue;
466            }
467            let y = p0.y + t * dy;
468            if y + eps >= min_y && y <= max_y + eps {
469                candidates.push((t, LayoutPoint { x: x_edge, y }));
470            }
471        }
472    }
473
474    if dy.abs() > eps {
475        for y_edge in [min_y, max_y] {
476            let t = (y_edge - p0.y) / dy;
477            if t < -eps || t > 1.0 + eps {
478                continue;
479            }
480            let x = p0.x + t * dx;
481            if x + eps >= min_x && x <= max_x + eps {
482                candidates.push((t, LayoutPoint { x, y: y_edge }));
483            }
484        }
485    }
486
487    candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
488    candidates
489        .into_iter()
490        .find(|(t, _)| *t >= 0.0)
491        .map(|(_, p)| p)
492}
493
494fn terminal_path_for_edge(
495    points: &[LayoutPoint],
496    from_rect: Rect,
497    to_rect: Rect,
498) -> Vec<LayoutPoint> {
499    if points.len() < 2 {
500        return points.to_vec();
501    }
502    let mut out = points.to_vec();
503
504    if let Some(p) = intersect_segment_with_rect(&out[0], &out[1], from_rect) {
505        out[0] = p;
506    }
507    let last = out.len() - 1;
508    if let Some(p) = intersect_segment_with_rect(&out[last], &out[last - 1], to_rect) {
509        out[last] = p;
510    }
511
512    out
513}
514
515fn layout_prepared(
516    prepared: &mut PreparedGraph,
517    node_label_metrics_by_id: &HashMap<String, (f64, f64)>,
518) -> Result<(LayoutFragments, Rect)> {
519    let mut fragments = LayoutFragments {
520        nodes: IndexMap::new(),
521        edges: Vec::new(),
522    };
523
524    let extracted_ids: Vec<String> = prepared.extracted.keys().cloned().collect();
525    let mut extracted_fragments: BTreeMap<String, (LayoutFragments, Rect)> = BTreeMap::new();
526    for id in extracted_ids {
527        let sub = prepared.extracted.get_mut(&id).expect("exists");
528        let (sub_frag, sub_bounds) = layout_prepared(sub, node_label_metrics_by_id)?;
529
530        // Mermaid sizes extracted cluster placeholders using the rendered SVG bbox of the
531        // recursively rendered cluster (`updateNodeBounds`). That bbox includes Dagre's implicit
532        // "cluster padding" around the laid out content. In Dagre, this padding is effectively
533        // `ranksep` (after `makeSpaceForEdgeLabels`), split across both sides.
534        //
535        // Our headless pipeline does not render extracted clusters as a separate SVG subtree, so
536        // inflate the computed bounds to match the padding that Mermaid's renderer bakes into the
537        // placeholder node size.
538        let pad = sub.graph.graph().ranksep.max(0.0);
539        let sub_bounds = Rect::from_min_max(
540            sub_bounds.min_x() - pad,
541            sub_bounds.min_y() - pad,
542            sub_bounds.max_x() + pad,
543            sub_bounds.max_y() + pad,
544        );
545
546        extracted_fragments.insert(id, (sub_frag, sub_bounds));
547    }
548
549    for (id, (_sub_frag, bounds)) in &extracted_fragments {
550        let Some(n) = prepared.graph.node_mut(id) else {
551            return Err(Error::InvalidModel {
552                message: format!("missing cluster placeholder node: {id}"),
553            });
554        };
555        n.width = bounds.width().max(1.0);
556        n.height = bounds.height().max(1.0);
557    }
558
559    // Mermaid's dagre wrapper always sets `compound: true`, and Dagre's ranker expects a connected
560    // graph. `dugong::layout_dagreish` mirrors Dagre's full pipeline (including `nestingGraph`)
561    // and should be used for class diagrams even when there are no explicit clusters.
562    dugong::layout_dagreish(&mut prepared.graph);
563
564    // Mermaid does not render Dagre's internal dummy nodes/edges (border nodes, edge label nodes,
565    // nesting artifacts). Filter them out before computing bounds and before merging extracted
566    // layouts back into the parent.
567    let mut dummy_nodes: HashSet<String> = HashSet::new();
568    for id in prepared.graph.node_ids() {
569        let Some(n) = prepared.graph.node(&id) else {
570            continue;
571        };
572        if n.dummy.is_some() {
573            dummy_nodes.insert(id);
574            continue;
575        }
576        let is_cluster =
577            !prepared.graph.children(&id).is_empty() || prepared.extracted.contains_key(&id);
578        let (label_width, label_height) = node_label_metrics_by_id
579            .get(id.as_str())
580            .copied()
581            .map(|(w, h)| (Some(w), Some(h)))
582            .unwrap_or((None, None));
583        fragments.nodes.insert(
584            id.clone(),
585            LayoutNode {
586                id: id.clone(),
587                x: n.x.unwrap_or(0.0),
588                y: n.y.unwrap_or(0.0),
589                width: n.width,
590                height: n.height,
591                is_cluster,
592                label_width,
593                label_height,
594            },
595        );
596    }
597
598    for key in prepared.graph.edge_keys() {
599        let Some(e) = prepared.graph.edge_by_key(&key) else {
600            continue;
601        };
602        if e.nesting_edge {
603            continue;
604        }
605        if dummy_nodes.contains(&key.v) || dummy_nodes.contains(&key.w) {
606            continue;
607        }
608        if !fragments.nodes.contains_key(&key.v) || !fragments.nodes.contains_key(&key.w) {
609            continue;
610        }
611        let id = key
612            .name
613            .clone()
614            .unwrap_or_else(|| format!("edge:{}:{}", key.v, key.w));
615
616        let label = if e.width > 0.0 && e.height > 0.0 {
617            Some(LayoutLabel {
618                x: e.x.unwrap_or(0.0),
619                y: e.y.unwrap_or(0.0),
620                width: e.width,
621                height: e.height,
622            })
623        } else {
624            None
625        };
626
627        let points = e
628            .points
629            .iter()
630            .map(|p| LayoutPoint { x: p.x, y: p.y })
631            .collect::<Vec<_>>();
632
633        let edge = LayoutEdge {
634            id,
635            from: key.v.clone(),
636            to: key.w.clone(),
637            from_cluster: None,
638            to_cluster: None,
639            points,
640            label,
641            start_label_left: None,
642            start_label_right: None,
643            end_label_left: None,
644            end_label_right: None,
645            start_marker: None,
646            end_marker: None,
647            stroke_dasharray: None,
648        };
649
650        let terminals = edge_terminal_metrics_from_extras(e);
651        let has_terminals = terminals.start_left.is_some()
652            || terminals.start_right.is_some()
653            || terminals.end_left.is_some()
654            || terminals.end_right.is_some();
655        let terminal_meta = if has_terminals { Some(terminals) } else { None };
656
657        fragments.edges.push((edge, terminal_meta));
658    }
659
660    for (cluster_id, (mut sub_frag, sub_bounds)) in extracted_fragments {
661        let Some(cluster_node) = fragments.nodes.get(&cluster_id).cloned() else {
662            return Err(Error::InvalidModel {
663                message: format!("missing cluster placeholder layout: {cluster_id}"),
664            });
665        };
666        let (sub_cx, sub_cy) = sub_bounds.center();
667        let dx = cluster_node.x - sub_cx;
668        let dy = cluster_node.y - sub_cy;
669
670        for n in sub_frag.nodes.values_mut() {
671            n.x += dx;
672            n.y += dy;
673        }
674        for (e, _t) in &mut sub_frag.edges {
675            for p in &mut e.points {
676                p.x += dx;
677                p.y += dy;
678            }
679            if let Some(l) = e.label.as_mut() {
680                l.x += dx;
681                l.y += dy;
682            }
683        }
684
685        // The extracted subgraph includes its own copy of the cluster root node so bounds match
686        // Mermaid's `updateNodeBounds(...)`. Do not merge that node back into the parent layout,
687        // otherwise we'd overwrite the placeholder position computed by the parent graph layout.
688        let _ = sub_frag.nodes.swap_remove(&cluster_id);
689
690        fragments.nodes.extend(sub_frag.nodes);
691        fragments.edges.extend(sub_frag.edges);
692    }
693
694    let mut points: Vec<(f64, f64)> = Vec::new();
695    for n in fragments.nodes.values() {
696        let r = Rect::from_center(n.x, n.y, n.width, n.height);
697        points.push((r.min_x(), r.min_y()));
698        points.push((r.max_x(), r.max_y()));
699    }
700    for (e, _t) in &fragments.edges {
701        for p in &e.points {
702            points.push((p.x, p.y));
703        }
704        if let Some(l) = &e.label {
705            let r = Rect::from_center(l.x, l.y, l.width, l.height);
706            points.push((r.min_x(), r.min_y()));
707            points.push((r.max_x(), r.max_y()));
708        }
709    }
710    let bounds = Bounds::from_points(points)
711        .map(|b| Rect::from_min_max(b.min_x, b.min_y, b.max_x, b.max_y))
712        .unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
713
714    Ok((fragments, bounds))
715}
716
717fn class_text_style(effective_config: &Value) -> TextStyle {
718    // Mermaid defaults to `"trebuchet ms", verdana, arial, sans-serif`. Class diagram labels are
719    // rendered via HTML `<foreignObject>` and inherit the global font family.
720    let font_family = config_string(effective_config, &["fontFamily"])
721        .or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
722        .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
723    // Mermaid class diagram node labels inherit the global `fontSize` (via the root `#id{font-size}` rule)
724    // and render via HTML labels (`foreignObject`). Prefer the global value for sizing/layout parity.
725    let font_size = config_f64(effective_config, &["fontSize"])
726        .or_else(|| config_f64(effective_config, &["class", "fontSize"]))
727        .unwrap_or(16.0)
728        .max(1.0);
729    TextStyle {
730        font_family,
731        font_size,
732        font_weight: None,
733    }
734}
735
736fn class_box_dimensions(
737    node: &ClassNode,
738    measurer: &dyn TextMeasurer,
739    text_style: &TextStyle,
740    wrap_mode: WrapMode,
741    padding: f64,
742    hide_empty_members_box: bool,
743    capture_row_metrics: bool,
744) -> (f64, f64, Option<ClassNodeRowMetrics>) {
745    // Mermaid class nodes are sized by rendering the label groups (`textHelper(...)`) and taking
746    // the resulting SVG bbox (`getBBox()`), then expanding by class padding (see upstream:
747    // `rendering-elements/shapes/classBox.ts` + `diagrams/class/shapeUtil.ts`).
748    //
749    // Emulate that sizing logic deterministically using the same text measurer.
750    let use_html_labels = matches!(wrap_mode, WrapMode::HtmlLike);
751    let padding = padding.max(0.0);
752    let gap = padding;
753    let text_padding = if use_html_labels { 0.0 } else { 3.0 };
754
755    fn measure_label(
756        measurer: &dyn TextMeasurer,
757        text: &str,
758        style: &TextStyle,
759        wrap_mode: WrapMode,
760    ) -> crate::text::TextMetrics {
761        // Mermaid class diagram text uses `createText(..., { classes: 'markdown-node-label' })`,
762        // which applies Markdown formatting for both SVG-label and HTML-label modes.
763        //
764        // The common case is plain text; keep the fast path for labels that do not appear to use
765        // Markdown markers.
766        if text.contains('*') || text.contains('_') || text.contains('`') {
767            crate::text::measure_markdown_with_flowchart_bold_deltas(
768                measurer, text, style, None, wrap_mode,
769            )
770        } else {
771            measurer.measure_wrapped(text, style, None, wrap_mode)
772        }
773    }
774
775    fn label_rect(m: crate::text::TextMetrics, y_offset: f64) -> Option<Rect> {
776        if !(m.width.is_finite() && m.height.is_finite()) {
777            return None;
778        }
779        let w = m.width.max(0.0);
780        let h = m.height.max(0.0);
781        if w <= 0.0 || h <= 0.0 {
782            return None;
783        }
784        let lines = m.line_count.max(1) as f64;
785        let y = y_offset - (h / (2.0 * lines));
786        Some(Rect::from_min_max(0.0, y, w, y + h))
787    }
788
789    // Annotation group: Mermaid only renders the first annotation.
790    let mut annotation_rect: Option<Rect> = None;
791    let mut annotation_group_height = 0.0;
792    if let Some(a) = node.annotations.first() {
793        let t = format!("\u{00AB}{}\u{00BB}", decode_entities_minimal(a.trim()));
794        let m = measure_label(measurer, &t, text_style, wrap_mode);
795        annotation_rect = label_rect(m, 0.0);
796        if let Some(r) = annotation_rect {
797            annotation_group_height = r.height().max(0.0);
798        }
799    }
800
801    // Title label group (bold).
802    let mut title_text = decode_entities_minimal(&node.text);
803    if !use_html_labels && title_text.starts_with('\\') {
804        title_text = title_text.trim_start_matches('\\').to_string();
805    }
806    // Mermaid renders class titles as bold (`font-weight: bolder`) and sizes boxes via SVG bbox.
807    // The vendored text measurer does not model bold glyph widths, so apply the same flowchart
808    // bold deltas used for Markdown `<strong>` spans.
809    let title_md = format!("**{title_text}**");
810    let title_metrics = crate::text::measure_markdown_with_flowchart_bold_deltas(
811        measurer, &title_md, text_style, None, wrap_mode,
812    );
813    let title_rect = label_rect(title_metrics, 0.0);
814    let title_group_height = title_rect.map(|r| r.height()).unwrap_or(0.0);
815
816    // Members group.
817    let mut members_rect: Option<Rect> = None;
818    let mut members_metrics_out: Option<Vec<crate::text::TextMetrics>> =
819        capture_row_metrics.then(|| Vec::with_capacity(node.members.len()));
820    {
821        let mut y_offset = 0.0;
822        for m in &node.members {
823            let mut t = decode_entities_minimal(m.display_text.trim());
824            if !use_html_labels && t.starts_with('\\') {
825                t = t.trim_start_matches('\\').to_string();
826            }
827            let metrics = measure_label(measurer, &t, text_style, wrap_mode);
828            if let Some(out) = members_metrics_out.as_mut() {
829                out.push(metrics);
830            }
831            if let Some(r) = label_rect(metrics, y_offset) {
832                if let Some(ref mut cur) = members_rect {
833                    cur.union(r);
834                } else {
835                    members_rect = Some(r);
836                }
837            }
838            y_offset += metrics.height.max(0.0) + text_padding;
839        }
840    }
841    let mut members_group_height = members_rect.map(|r| r.height()).unwrap_or(0.0);
842    if members_group_height <= 0.0 {
843        // Mermaid reserves half a gap when the members group is empty.
844        members_group_height = (gap / 2.0).max(0.0);
845    }
846
847    // Methods group.
848    let mut methods_rect: Option<Rect> = None;
849    let mut methods_metrics_out: Option<Vec<crate::text::TextMetrics>> =
850        capture_row_metrics.then(|| Vec::with_capacity(node.methods.len()));
851    {
852        let mut y_offset = 0.0;
853        for m in &node.methods {
854            let mut t = decode_entities_minimal(m.display_text.trim());
855            if !use_html_labels && t.starts_with('\\') {
856                t = t.trim_start_matches('\\').to_string();
857            }
858            let metrics = measure_label(measurer, &t, text_style, wrap_mode);
859            if let Some(out) = methods_metrics_out.as_mut() {
860                out.push(metrics);
861            }
862            if let Some(r) = label_rect(metrics, y_offset) {
863                if let Some(ref mut cur) = methods_rect {
864                    cur.union(r);
865                } else {
866                    methods_rect = Some(r);
867                }
868            }
869            y_offset += metrics.height.max(0.0) + text_padding;
870        }
871    }
872
873    // Combine into the bbox returned by `textHelper(...)`.
874    let mut bbox_opt: Option<Rect> = None;
875
876    // annotation-group: centered horizontally (`translate(-w/2, 0)`).
877    if let Some(mut r) = annotation_rect {
878        let w = r.width();
879        r.translate(-w / 2.0, 0.0);
880        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
881            cur.union(r);
882            cur
883        } else {
884            r
885        });
886    }
887
888    // label-group: centered and shifted down by annotation height.
889    if let Some(mut r) = title_rect {
890        let w = r.width();
891        r.translate(-w / 2.0, annotation_group_height);
892        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
893            cur.union(r);
894            cur
895        } else {
896            r
897        });
898    }
899
900    // members-group: left-aligned, shifted down by label height + gap*2.
901    if let Some(mut r) = members_rect {
902        let dy = annotation_group_height + title_group_height + gap * 2.0;
903        r.translate(0.0, dy);
904        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
905            cur.union(r);
906            cur
907        } else {
908            r
909        });
910    }
911
912    // methods-group: left-aligned, shifted down by label height + members height + gap*4.
913    if let Some(mut r) = methods_rect {
914        let dy = annotation_group_height + title_group_height + (members_group_height + gap * 4.0);
915        r.translate(0.0, dy);
916        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
917            cur.union(r);
918            cur
919        } else {
920            r
921        });
922    }
923
924    let bbox = bbox_opt.unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
925    let w = bbox.width().max(0.0);
926    let mut h = bbox.height().max(0.0);
927
928    // Mermaid adjusts bbox height depending on which compartments exist.
929    if node.members.is_empty() && node.methods.is_empty() {
930        h += gap;
931    } else if !node.members.is_empty() && node.methods.is_empty() {
932        h += gap * 2.0;
933    }
934
935    let render_extra_box =
936        node.members.is_empty() && node.methods.is_empty() && !hide_empty_members_box;
937
938    // The Dagre node bounds come from the rectangle passed to `updateNodeBounds`.
939    let mut rect_w = w + 2.0 * padding;
940    let mut rect_h = h + 2.0 * padding;
941    if render_extra_box {
942        rect_h += padding * 2.0;
943    } else if node.members.is_empty() && node.methods.is_empty() {
944        rect_h -= padding;
945    }
946
947    if node.type_param == "group" {
948        rect_w = rect_w.max(500.0);
949    }
950
951    let row_metrics = capture_row_metrics.then(|| ClassNodeRowMetrics {
952        members: members_metrics_out.unwrap_or_default(),
953        methods: methods_metrics_out.unwrap_or_default(),
954    });
955
956    (rect_w.max(1.0), rect_h.max(1.0), row_metrics)
957}
958
959fn note_dimensions(
960    text: &str,
961    measurer: &dyn TextMeasurer,
962    text_style: &TextStyle,
963    wrap_mode: WrapMode,
964    padding: f64,
965) -> (f64, f64, crate::text::TextMetrics) {
966    let p = padding.max(0.0);
967    let label = decode_entities_minimal(text);
968    let m = measurer.measure_wrapped(&label, text_style, None, wrap_mode);
969    (m.width + p, m.height + p, m)
970}
971
972fn label_metrics(
973    text: &str,
974    measurer: &dyn TextMeasurer,
975    text_style: &TextStyle,
976    wrap_mode: WrapMode,
977) -> (f64, f64) {
978    if text.trim().is_empty() {
979        return (0.0, 0.0);
980    }
981    let t = decode_entities_minimal(text);
982    let m = measurer.measure_wrapped(&t, text_style, None, wrap_mode);
983    (m.width.max(0.0), m.height.max(0.0))
984}
985
986fn set_extras_label_metrics(extras: &mut BTreeMap<String, Value>, key: &str, w: f64, h: f64) {
987    let obj = Value::Object(
988        [
989            ("width".to_string(), Value::from(w)),
990            ("height".to_string(), Value::from(h)),
991        ]
992        .into_iter()
993        .collect(),
994    );
995    extras.insert(key.to_string(), obj);
996}
997
998pub fn layout_class_diagram_v2(
999    semantic: &Value,
1000    effective_config: &Value,
1001    measurer: &dyn TextMeasurer,
1002) -> Result<ClassDiagramV2Layout> {
1003    let model: ClassDiagramModel = crate::json::from_value_ref(semantic)?;
1004    layout_class_diagram_v2_typed(&model, effective_config, measurer)
1005}
1006
1007pub fn layout_class_diagram_v2_typed(
1008    model: &ClassDiagramModel,
1009    effective_config: &Value,
1010    measurer: &dyn TextMeasurer,
1011) -> Result<ClassDiagramV2Layout> {
1012    let diagram_dir = rank_dir_from(&model.direction);
1013    let conf = effective_config
1014        .get("flowchart")
1015        .or_else(|| effective_config.get("class"))
1016        .unwrap_or(effective_config);
1017    let nodesep = config_f64(conf, &["nodeSpacing"]).unwrap_or(50.0);
1018    let ranksep = config_f64(conf, &["rankSpacing"]).unwrap_or(50.0);
1019
1020    let global_html_labels = config_bool(effective_config, &["htmlLabels"]).unwrap_or(true);
1021    let flowchart_html_labels =
1022        config_bool(effective_config, &["flowchart", "htmlLabels"]).unwrap_or(true);
1023    let wrap_mode_node = if global_html_labels {
1024        WrapMode::HtmlLike
1025    } else {
1026        WrapMode::SvgLike
1027    };
1028    let wrap_mode_label = if flowchart_html_labels {
1029        WrapMode::HtmlLike
1030    } else {
1031        WrapMode::SvgLike
1032    };
1033
1034    // Mermaid defaults `config.class.padding` to 12.
1035    let class_padding = config_f64(effective_config, &["class", "padding"]).unwrap_or(12.0);
1036    let namespace_padding = config_f64(effective_config, &["flowchart", "padding"]).unwrap_or(15.0);
1037    let hide_empty_members_box =
1038        config_bool(effective_config, &["class", "hideEmptyMembersBox"]).unwrap_or(false);
1039
1040    let text_style = class_text_style(effective_config);
1041    let capture_row_metrics = matches!(wrap_mode_node, WrapMode::HtmlLike);
1042    let capture_label_metrics = matches!(wrap_mode_label, WrapMode::HtmlLike);
1043    let mut class_row_metrics_by_id: FxHashMap<String, Arc<ClassNodeRowMetrics>> =
1044        FxHashMap::default();
1045    let mut node_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
1046
1047    let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
1048        directed: true,
1049        multigraph: true,
1050        compound: true,
1051    });
1052    g.set_graph(GraphLabel {
1053        rankdir: diagram_dir,
1054        nodesep,
1055        ranksep,
1056        // Mermaid uses fixed graph margins in its Dagre wrapper for class diagrams, but our SVG
1057        // renderer re-introduces that margin when computing the viewport. Keep layout coordinates
1058        // margin-free here to avoid double counting.
1059        marginx: 0.0,
1060        marginy: 0.0,
1061        ..Default::default()
1062    });
1063
1064    for id in model.namespaces.keys() {
1065        // Mermaid's dagre-wrapper assigns a concrete size to namespace nodes (cluster placeholders)
1066        // based on the rendered label bbox plus padding. Doing the same here helps compound
1067        // constraints match upstream (especially for LR layouts).
1068        let title = id.clone();
1069        let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1070        let w = (tw + 2.0 * namespace_padding).max(1.0);
1071        let h = (th + 2.0 * namespace_padding).max(1.0);
1072        g.set_node(
1073            id.clone(),
1074            NodeLabel {
1075                width: w,
1076                height: h,
1077                ..Default::default()
1078            },
1079        );
1080    }
1081
1082    for c in model.classes.values() {
1083        let (w, h, row_metrics) = class_box_dimensions(
1084            c,
1085            measurer,
1086            &text_style,
1087            wrap_mode_node,
1088            class_padding,
1089            hide_empty_members_box,
1090            capture_row_metrics,
1091        );
1092        if let Some(rm) = row_metrics {
1093            class_row_metrics_by_id.insert(c.id.clone(), Arc::new(rm));
1094        }
1095        g.set_node(
1096            c.id.clone(),
1097            NodeLabel {
1098                width: w,
1099                height: h,
1100                ..Default::default()
1101            },
1102        );
1103    }
1104
1105    // Interface nodes (lollipop syntax).
1106    for iface in &model.interfaces {
1107        let label = decode_entities_minimal(iface.label.trim());
1108        let (tw, th) = label_metrics(&label, measurer, &text_style, wrap_mode_label);
1109        if capture_label_metrics {
1110            node_label_metrics_by_id.insert(iface.id.clone(), (tw, th));
1111        }
1112        g.set_node(
1113            iface.id.clone(),
1114            NodeLabel {
1115                width: tw.max(1.0),
1116                height: th.max(1.0),
1117                ..Default::default()
1118            },
1119        );
1120    }
1121
1122    for n in &model.notes {
1123        let (w, h, metrics) = note_dimensions(
1124            &n.text,
1125            measurer,
1126            &text_style,
1127            wrap_mode_label,
1128            namespace_padding,
1129        );
1130        if capture_label_metrics {
1131            node_label_metrics_by_id.insert(
1132                n.id.clone(),
1133                (metrics.width.max(0.0), metrics.height.max(0.0)),
1134            );
1135        }
1136        g.set_node(
1137            n.id.clone(),
1138            NodeLabel {
1139                width: w.max(1.0),
1140                height: h.max(1.0),
1141                ..Default::default()
1142            },
1143        );
1144    }
1145
1146    if g.options().compound {
1147        // Mermaid assigns parents based on the class' `parent` field (see upstream
1148        // `addClasses(..., parent)` + `g.setParent(vertex.id, parent)`).
1149        for c in model.classes.values() {
1150            if let Some(parent) = c
1151                .parent
1152                .as_ref()
1153                .map(|s| s.trim())
1154                .filter(|s| !s.is_empty())
1155            {
1156                if model.namespaces.contains_key(parent) {
1157                    g.set_parent(c.id.clone(), parent.to_string());
1158                }
1159            }
1160        }
1161
1162        // Keep interface nodes inside the same namespace cluster as their owning class.
1163        for iface in &model.interfaces {
1164            let Some(cls) = model.classes.get(iface.class_id.as_str()) else {
1165                continue;
1166            };
1167            let Some(parent) = cls
1168                .parent
1169                .as_ref()
1170                .map(|s| s.trim())
1171                .filter(|s| !s.is_empty())
1172            else {
1173                continue;
1174            };
1175            if model.namespaces.contains_key(parent) {
1176                g.set_parent(iface.id.clone(), parent.to_string());
1177            }
1178        }
1179    }
1180
1181    for rel in &model.relations {
1182        let (lw, lh) = label_metrics(&rel.title, measurer, &text_style, wrap_mode_label);
1183        let start_text = if rel.relation_title_1 == "none" {
1184            String::new()
1185        } else {
1186            rel.relation_title_1.clone()
1187        };
1188        let end_text = if rel.relation_title_2 == "none" {
1189            String::new()
1190        } else {
1191            rel.relation_title_2.clone()
1192        };
1193
1194        let (srw, srh) = label_metrics(&start_text, measurer, &text_style, wrap_mode_label);
1195        let (elw, elh) = label_metrics(&end_text, measurer, &text_style, wrap_mode_label);
1196
1197        let start_marker = if rel.relation.type1 == -1 { 0.0 } else { 10.0 };
1198        let end_marker = if rel.relation.type2 == -1 { 0.0 } else { 10.0 };
1199
1200        let mut el = EdgeLabel {
1201            width: lw,
1202            height: lh,
1203            labelpos: LabelPos::C,
1204            labeloffset: 10.0,
1205            minlen: 1,
1206            weight: 1.0,
1207            ..Default::default()
1208        };
1209        if srw > 0.0 && srh > 0.0 {
1210            set_extras_label_metrics(&mut el.extras, "startRight", srw, srh);
1211        }
1212        if elw > 0.0 && elh > 0.0 {
1213            set_extras_label_metrics(&mut el.extras, "endLeft", elw, elh);
1214        }
1215        el.extras
1216            .insert("startMarker".to_string(), Value::from(start_marker));
1217        el.extras
1218            .insert("endMarker".to_string(), Value::from(end_marker));
1219
1220        g.set_edge_named(
1221            rel.id1.clone(),
1222            rel.id2.clone(),
1223            Some(rel.id.clone()),
1224            Some(el),
1225        );
1226    }
1227
1228    let start_note_edge_id = model.relations.len() + 1;
1229    for (i, note) in model.notes.iter().enumerate() {
1230        let Some(class_id) = note.class_id.as_ref() else {
1231            continue;
1232        };
1233        if !model.classes.contains_key(class_id) {
1234            continue;
1235        }
1236        let edge_id = format!("edgeNote{}", start_note_edge_id + i);
1237        let el = EdgeLabel {
1238            width: 0.0,
1239            height: 0.0,
1240            labelpos: LabelPos::C,
1241            labeloffset: 10.0,
1242            minlen: 1,
1243            weight: 1.0,
1244            ..Default::default()
1245        };
1246        g.set_edge_named(note.id.clone(), class_id.clone(), Some(edge_id), Some(el));
1247    }
1248
1249    let prefer_dagreish_disconnected = !model.interfaces.is_empty();
1250    let mut prepared = prepare_graph(g, 0, prefer_dagreish_disconnected)?;
1251    let (mut fragments, _bounds) = layout_prepared(&mut prepared, &node_label_metrics_by_id)?;
1252
1253    let mut node_rect_by_id: HashMap<String, Rect> = HashMap::new();
1254    for n in fragments.nodes.values() {
1255        node_rect_by_id.insert(n.id.clone(), Rect::from_center(n.x, n.y, n.width, n.height));
1256    }
1257
1258    for (edge, terminal_meta) in fragments.edges.iter_mut() {
1259        let Some(meta) = terminal_meta.clone() else {
1260            continue;
1261        };
1262        let (from_rect, to_rect, points) = if let (Some(from), Some(to)) = (
1263            node_rect_by_id.get(edge.from.as_str()).copied(),
1264            node_rect_by_id.get(edge.to.as_str()).copied(),
1265        ) {
1266            (
1267                Some(from),
1268                Some(to),
1269                terminal_path_for_edge(&edge.points, from, to),
1270            )
1271        } else {
1272            (None, None, edge.points.clone())
1273        };
1274
1275        if let Some((w, h)) = meta.start_left {
1276            if let Some((x, y)) =
1277                calc_terminal_label_position(meta.start_marker, TerminalPos::StartLeft, &points)
1278            {
1279                let (x, y) = from_rect
1280                    .map(|r| nudge_point_outside_rect(x, y, r))
1281                    .unwrap_or((x, y));
1282                edge.start_label_left = Some(LayoutLabel {
1283                    x,
1284                    y,
1285                    width: w,
1286                    height: h,
1287                });
1288            }
1289        }
1290        if let Some((w, h)) = meta.start_right {
1291            if let Some((x, y)) =
1292                calc_terminal_label_position(meta.start_marker, TerminalPos::StartRight, &points)
1293            {
1294                let (x, y) = from_rect
1295                    .map(|r| nudge_point_outside_rect(x, y, r))
1296                    .unwrap_or((x, y));
1297                edge.start_label_right = Some(LayoutLabel {
1298                    x,
1299                    y,
1300                    width: w,
1301                    height: h,
1302                });
1303            }
1304        }
1305        if let Some((w, h)) = meta.end_left {
1306            if let Some((x, y)) =
1307                calc_terminal_label_position(meta.end_marker, TerminalPos::EndLeft, &points)
1308            {
1309                let (x, y) = to_rect
1310                    .map(|r| nudge_point_outside_rect(x, y, r))
1311                    .unwrap_or((x, y));
1312                edge.end_label_left = Some(LayoutLabel {
1313                    x,
1314                    y,
1315                    width: w,
1316                    height: h,
1317                });
1318            }
1319        }
1320        if let Some((w, h)) = meta.end_right {
1321            if let Some((x, y)) =
1322                calc_terminal_label_position(meta.end_marker, TerminalPos::EndRight, &points)
1323            {
1324                let (x, y) = to_rect
1325                    .map(|r| nudge_point_outside_rect(x, y, r))
1326                    .unwrap_or((x, y));
1327                edge.end_label_right = Some(LayoutLabel {
1328                    x,
1329                    y,
1330                    width: w,
1331                    height: h,
1332                });
1333            }
1334        }
1335    }
1336
1337    let title_margin_top = config_f64(
1338        effective_config,
1339        &["flowchart", "subGraphTitleMargin", "top"],
1340    )
1341    .unwrap_or(0.0);
1342    let title_margin_bottom = config_f64(
1343        effective_config,
1344        &["flowchart", "subGraphTitleMargin", "bottom"],
1345    )
1346    .unwrap_or(0.0);
1347
1348    let mut clusters: Vec<LayoutCluster> = Vec::new();
1349    // Mermaid renders namespaces as Dagre clusters. The cluster geometry comes from the Dagre
1350    // compound layout (not a post-hoc union of class-node bboxes). Use the computed namespace
1351    // node x/y/width/height and mirror `clusters.js` sizing tweaks for title width.
1352    for id in model.namespaces.keys() {
1353        let Some(ns_node) = fragments.nodes.get(id.as_str()) else {
1354            continue;
1355        };
1356        let cx = ns_node.x;
1357        let cy = ns_node.y;
1358        let base_w = ns_node.width.max(1.0);
1359        let base_h = ns_node.height.max(1.0);
1360
1361        let title = id.clone();
1362        let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1363        let min_title_w = (tw + namespace_padding).max(1.0);
1364        let width = if base_w <= min_title_w {
1365            min_title_w
1366        } else {
1367            base_w
1368        };
1369        let diff = if base_w <= min_title_w {
1370            (width - base_w) / 2.0 - namespace_padding
1371        } else {
1372            -namespace_padding
1373        };
1374        let offset_y = th - namespace_padding / 2.0;
1375        let title_label = LayoutLabel {
1376            x: cx,
1377            y: (cy - base_h / 2.0) + title_margin_top + th / 2.0,
1378            width: tw,
1379            height: th,
1380        };
1381
1382        clusters.push(LayoutCluster {
1383            id: id.clone(),
1384            x: cx,
1385            y: cy,
1386            width,
1387            height: base_h,
1388            diff,
1389            offset_y,
1390            title: title.clone(),
1391            title_label,
1392            requested_dir: None,
1393            effective_dir: normalize_dir(&model.direction),
1394            padding: namespace_padding,
1395            title_margin_top,
1396            title_margin_bottom,
1397        });
1398    }
1399
1400    // Keep snapshots deterministic. The Dagre-ish pipeline may insert dummy nodes/edges in
1401    // iteration-dependent order, so sort the emitted layout lists by stable identifiers.
1402    let mut nodes: Vec<LayoutNode> = fragments.nodes.into_values().collect();
1403    nodes.sort_by(|a, b| a.id.cmp(&b.id));
1404
1405    let mut edges: Vec<LayoutEdge> = fragments.edges.into_iter().map(|(e, _)| e).collect();
1406    edges.sort_by(|a, b| a.id.cmp(&b.id));
1407
1408    clusters.sort_by(|a, b| a.id.cmp(&b.id));
1409
1410    let bounds = compute_bounds(&nodes, &edges, &clusters);
1411
1412    Ok(ClassDiagramV2Layout {
1413        nodes,
1414        edges,
1415        clusters,
1416        bounds,
1417        class_row_metrics_by_id,
1418    })
1419}
1420
1421fn compute_bounds(
1422    nodes: &[LayoutNode],
1423    edges: &[LayoutEdge],
1424    clusters: &[LayoutCluster],
1425) -> Option<Bounds> {
1426    let mut points: Vec<(f64, f64)> = Vec::new();
1427
1428    for c in clusters {
1429        let r = Rect::from_center(c.x, c.y, c.width, c.height);
1430        points.push((r.min_x(), r.min_y()));
1431        points.push((r.max_x(), r.max_y()));
1432        let lr = Rect::from_center(
1433            c.title_label.x,
1434            c.title_label.y,
1435            c.title_label.width,
1436            c.title_label.height,
1437        );
1438        points.push((lr.min_x(), lr.min_y()));
1439        points.push((lr.max_x(), lr.max_y()));
1440    }
1441
1442    for n in nodes {
1443        let r = Rect::from_center(n.x, n.y, n.width, n.height);
1444        points.push((r.min_x(), r.min_y()));
1445        points.push((r.max_x(), r.max_y()));
1446    }
1447
1448    for e in edges {
1449        for p in &e.points {
1450            points.push((p.x, p.y));
1451        }
1452        for l in [
1453            e.label.as_ref(),
1454            e.start_label_left.as_ref(),
1455            e.start_label_right.as_ref(),
1456            e.end_label_left.as_ref(),
1457            e.end_label_right.as_ref(),
1458        ]
1459        .into_iter()
1460        .flatten()
1461        {
1462            let r = Rect::from_center(l.x, l.y, l.width, l.height);
1463            points.push((r.min_x(), r.min_y()));
1464            points.push((r.max_x(), r.max_y()));
1465        }
1466    }
1467
1468    Bounds::from_points(points)
1469}