1use crate::config::{
2 config_bool, config_f64, config_f64_css_px, config_f64_explicit_css_px, config_string,
3};
4use crate::entities::decode_entities_minimal;
5use crate::model::{
6 Bounds, ClassDiagramV2Layout, ClassNodeRowMetrics, LayoutCluster, LayoutEdge, LayoutLabel,
7 LayoutNode, LayoutPoint,
8};
9use crate::text::{TextMeasurer, TextStyle, WrapMode};
10use crate::{Error, Result};
11use dugong::graphlib::{Graph, GraphOptions};
12use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
13use indexmap::IndexMap;
14use rustc_hash::FxHashMap;
15use serde_json::Value;
16use std::collections::{BTreeMap, HashMap, HashSet};
17use std::sync::Arc;
18
19type ClassDiagramModel = merman_core::models::class_diagram::ClassDiagram;
20type ClassNode = merman_core::models::class_diagram::ClassNode;
21type ClassNote = merman_core::models::class_diagram::ClassNote;
22
23fn normalize_dir(direction: &str) -> String {
24 match direction.trim().to_uppercase().as_str() {
25 "TB" | "TD" => "TB".to_string(),
26 "BT" => "BT".to_string(),
27 "LR" => "LR".to_string(),
28 "RL" => "RL".to_string(),
29 other => other.to_string(),
30 }
31}
32
33fn rank_dir_from(direction: &str) -> RankDir {
34 match normalize_dir(direction).as_str() {
35 "TB" => RankDir::TB,
36 "BT" => RankDir::BT,
37 "LR" => RankDir::LR,
38 "RL" => RankDir::RL,
39 _ => RankDir::TB,
40 }
41}
42
43fn class_dom_decl_order_index(dom_id: &str) -> usize {
44 dom_id
45 .rsplit_once('-')
46 .and_then(|(_, suffix)| suffix.parse::<usize>().ok())
47 .unwrap_or(usize::MAX)
48}
49
50pub(crate) fn class_namespace_ids_in_decl_order(model: &ClassDiagramModel) -> Vec<&str> {
51 let mut namespaces: Vec<_> = model.namespaces.values().collect();
52 namespaces.sort_by(|lhs, rhs| {
53 class_dom_decl_order_index(&lhs.dom_id)
54 .cmp(&class_dom_decl_order_index(&rhs.dom_id))
55 .then_with(|| lhs.id.cmp(&rhs.id))
56 });
57 namespaces.into_iter().map(|ns| ns.id.as_str()).collect()
58}
59
60pub(crate) fn class_namespace_label<'a>(model: &'a ClassDiagramModel, id: &'a str) -> &'a str {
61 model
62 .namespaces
63 .get(id)
64 .and_then(|ns| {
65 let label = ns.label.trim();
66 (!label.is_empty()).then_some(label)
67 })
68 .unwrap_or(id)
69}
70
71fn class_namespace_child_pairs(model: &ClassDiagramModel) -> HashSet<(&str, &str)> {
72 let mut pairs = HashSet::with_capacity(model.classes.len());
73 for class in model.classes.values() {
74 let Some(parent) = class
75 .parent
76 .as_deref()
77 .map(str::trim)
78 .filter(|parent| !parent.is_empty())
79 else {
80 continue;
81 };
82 let id = class.id.trim();
83 if id.is_empty() {
84 continue;
85 }
86 pairs.insert((parent, id));
87 }
88 pairs
89}
90
91type Rect = merman_core::geom::Box2;
92
93struct PreparedGraph {
94 graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
95 extracted: BTreeMap<String, PreparedGraph>,
96 injected_cluster_root_id: Option<String>,
97}
98
99fn extract_descendants(
100 graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
101 id: &str,
102 out: &mut Vec<String>,
103) {
104 let mut visited: HashSet<String> = HashSet::new();
105 let mut stack: Vec<String> = graph
106 .children(id)
107 .iter()
108 .rev()
109 .map(|s| s.to_string())
110 .collect();
111 while let Some(node) = stack.pop() {
112 if !visited.insert(node.clone()) {
113 continue;
114 }
115 out.push(node.clone());
116 let children = graph.children(&node);
117 for child in children.iter().rev() {
118 stack.push(child.to_string());
119 }
120 }
121}
122
123fn extract_cluster_copy_order(
124 graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
125 cluster_id: &str,
126 root_id: &str,
127 out: &mut Vec<String>,
128) {
129 let mut stack: Vec<(String, bool)> = vec![(cluster_id.to_string(), false)];
132 while let Some((node, expanded)) = stack.pop() {
133 if expanded {
134 if node != root_id {
135 out.push(node);
136 }
137 continue;
138 }
139
140 let children = graph.children(&node);
141 if children.is_empty() {
142 if node != root_id {
143 out.push(node);
144 }
145 continue;
146 }
147
148 stack.push((node, true));
149 for child in children.iter().rev() {
150 stack.push((child.to_string(), false));
151 }
152 }
153}
154
155fn is_descendant(descendants: &HashMap<String, HashSet<String>>, id: &str, ancestor: &str) -> bool {
156 descendants
157 .get(ancestor)
158 .is_some_and(|set| set.contains(id))
159}
160
161fn graph_parent_depths<N, E, G>(graph: &Graph<N, E, G>, ids: &[String]) -> HashMap<String, usize>
162where
163 N: Default + 'static,
164 E: Default + 'static,
165 G: Default,
166{
167 let mut depths: HashMap<String, usize> = HashMap::new();
168
169 for id in ids {
170 if depths.contains_key(id) {
171 continue;
172 }
173
174 let mut current = id.clone();
175 let mut chain: Vec<String> = Vec::new();
176 let mut seen: HashSet<String> = HashSet::new();
177 let mut base_depth = 0usize;
178
179 loop {
180 if let Some(depth) = depths.get(¤t).copied() {
181 base_depth = depth;
182 break;
183 }
184 if !seen.insert(current.clone()) {
185 break;
186 }
187 let Some(parent) = graph.parent(¤t).map(|s| s.to_string()) else {
188 break;
189 };
190 chain.push(current);
191 current = parent;
192 }
193
194 for node in chain.into_iter().rev() {
195 base_depth += 1;
196 depths.insert(node, base_depth);
197 }
198
199 depths.entry(id.clone()).or_insert(base_depth);
200 }
201
202 depths
203}
204
205fn prepare_graph(
206 mut graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
207 depth: usize,
208) -> Result<PreparedGraph> {
209 if depth > 10 {
210 return Ok(PreparedGraph {
211 graph,
212 extracted: BTreeMap::new(),
213 injected_cluster_root_id: None,
214 });
215 }
216
217 let cluster_ids: Vec<String> = graph
231 .node_ids()
232 .into_iter()
233 .filter(|id| !graph.children(id).is_empty())
234 .collect();
235 let parent_depths = graph_parent_depths(&graph, &cluster_ids);
236 if depth + parent_depths.values().copied().max().unwrap_or_default() > 10 {
237 return Ok(PreparedGraph {
238 graph,
239 extracted: BTreeMap::new(),
240 injected_cluster_root_id: None,
241 });
242 }
243
244 let mut descendants: HashMap<String, HashSet<String>> = HashMap::new();
245 for id in &cluster_ids {
246 let mut vec: Vec<String> = Vec::new();
247 extract_descendants(&graph, id, &mut vec);
248 descendants.insert(id.clone(), vec.into_iter().collect());
249 }
250
251 let mut external: HashMap<String, bool> =
252 cluster_ids.iter().map(|id| (id.clone(), false)).collect();
253 for id in &cluster_ids {
254 for e in graph.edge_keys() {
255 if e.v == *id || e.w == *id {
259 continue;
260 }
261 let d1 = is_descendant(&descendants, &e.v, id);
262 let d2 = is_descendant(&descendants, &e.w, id);
263 if d1 ^ d2 {
264 external.insert(id.clone(), true);
265 break;
266 }
267 }
268 }
269
270 let mut extracted: BTreeMap<String, PreparedGraph> = BTreeMap::new();
271 let candidate_clusters: Vec<String> = graph
272 .node_ids()
273 .into_iter()
274 .filter(|id| {
275 if depth + parent_depths.get(id).copied().unwrap_or(0) > 10 {
276 return false;
277 }
278 let has_children = !graph.children(id).is_empty();
279 let is_external = external.get(id).copied().unwrap_or(false);
280 has_children && !is_external
281 })
282 .collect();
283
284 for cluster_id in candidate_clusters {
285 if graph.children(&cluster_id).is_empty() {
286 continue;
287 }
288 let parent_dir = graph.graph().rankdir;
289 let dir = if parent_dir == RankDir::TB {
290 RankDir::LR
291 } else {
292 RankDir::TB
293 };
294
295 let nodesep = graph.graph().nodesep;
296 let ranksep = graph.graph().ranksep;
297
298 let (mut subgraph, moved_set) = extract_cluster_graph(&cluster_id, &mut graph)?;
299 subgraph.graph_mut().rankdir = dir;
300 subgraph.graph_mut().nodesep = nodesep;
301 subgraph.graph_mut().ranksep = ranksep;
302 subgraph.graph_mut().marginx = 8.0;
303 subgraph.graph_mut().marginy = 8.0;
304
305 let mut prepared = prepare_graph(subgraph, depth + 1)?;
306 for moved_id in &moved_set {
307 if let Some(child_prepared) = extracted.remove(moved_id) {
308 prepared.extracted.insert(moved_id.clone(), child_prepared);
309 }
310 }
311 prepared.injected_cluster_root_id = Some(cluster_id.clone());
312 extracted.insert(cluster_id, prepared);
313 }
314
315 Ok(PreparedGraph {
316 graph,
317 extracted,
318 injected_cluster_root_id: None,
319 })
320}
321
322fn extract_cluster_graph(
323 cluster_id: &str,
324 graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
325) -> Result<(Graph<NodeLabel, EdgeLabel, GraphLabel>, HashSet<String>)> {
326 if graph.children(cluster_id).is_empty() {
327 return Err(Error::InvalidModel {
328 message: format!("cluster has no children: {cluster_id}"),
329 });
330 }
331
332 let mut descendants: Vec<String> = Vec::new();
333 extract_cluster_copy_order(graph, cluster_id, cluster_id, &mut descendants);
334
335 let moved_set: HashSet<String> = descendants.iter().cloned().collect();
336
337 let mut sub = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
338 directed: true,
339 multigraph: true,
340 compound: true,
341 });
342
343 sub.set_graph(graph.graph().clone());
345
346 for id in &descendants {
347 let Some(label) = graph.node(id).cloned() else {
348 continue;
349 };
350 sub.set_node(id.clone(), label);
351 }
352
353 for key in graph.edge_keys() {
354 if moved_set.contains(&key.v) && moved_set.contains(&key.w) {
355 if let Some(label) = graph.edge_by_key(&key).cloned() {
356 sub.set_edge_named(key.v.clone(), key.w.clone(), key.name.clone(), Some(label));
357 }
358 }
359 }
360
361 for id in &descendants {
362 let Some(parent) = graph.parent(id) else {
363 continue;
364 };
365 if moved_set.contains(parent) {
366 sub.set_parent(id.clone(), parent.to_string());
367 }
368 }
369
370 for id in &descendants {
371 let _ = graph.remove_node(id);
372 }
373
374 Ok((sub, moved_set))
375}
376
377#[derive(Debug, Clone)]
378struct EdgeTerminalMetrics {
379 start_left: Option<(f64, f64)>,
380 start_right: Option<(f64, f64)>,
381 end_left: Option<(f64, f64)>,
382 end_right: Option<(f64, f64)>,
383 start_marker: f64,
384 end_marker: f64,
385}
386
387fn edge_terminal_metrics_from_extras(e: &EdgeLabel) -> EdgeTerminalMetrics {
388 let get_pair = |key: &str| -> Option<(f64, f64)> {
389 let obj = e.extras.get(key)?;
390 let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0);
391 let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0);
392 if w > 0.0 && h > 0.0 {
393 Some((w, h))
394 } else {
395 None
396 }
397 };
398 let start_marker = e
399 .extras
400 .get("startMarker")
401 .and_then(|v| v.as_f64())
402 .unwrap_or(0.0);
403 let end_marker = e
404 .extras
405 .get("endMarker")
406 .and_then(|v| v.as_f64())
407 .unwrap_or(0.0);
408 EdgeTerminalMetrics {
409 start_left: get_pair("startLeft"),
410 start_right: get_pair("startRight"),
411 end_left: get_pair("endLeft"),
412 end_right: get_pair("endRight"),
413 start_marker,
414 end_marker,
415 }
416}
417
418#[derive(Debug, Clone)]
419struct LayoutFragments {
420 nodes: IndexMap<String, LayoutNode>,
421 edges: Vec<(LayoutEdge, Option<EdgeTerminalMetrics>)>,
422}
423
424fn round_number(num: f64, precision: i32) -> f64 {
425 if !num.is_finite() {
426 return 0.0;
427 }
428 let factor = 10_f64.powi(precision);
429 (num * factor).round() / factor
430}
431
432fn distance(a: &LayoutPoint, b: Option<&LayoutPoint>) -> f64 {
433 let Some(b) = b else {
434 return 0.0;
435 };
436 let dx = a.x - b.x;
437 let dy = a.y - b.y;
438 (dx * dx + dy * dy).sqrt()
439}
440
441fn calculate_point(points: &[LayoutPoint], distance_to_traverse: f64) -> Option<LayoutPoint> {
442 if points.is_empty() {
443 return None;
444 }
445 let mut prev: Option<&LayoutPoint> = None;
446 let mut remaining = distance_to_traverse.max(0.0);
447 for p in points {
448 if let Some(prev_p) = prev {
449 let vector_distance = distance(p, Some(prev_p));
450 if vector_distance == 0.0 {
451 return Some(prev_p.clone());
452 }
453 if vector_distance < remaining {
454 remaining -= vector_distance;
455 } else {
456 let ratio = remaining / vector_distance;
457 if ratio <= 0.0 {
458 return Some(prev_p.clone());
459 }
460 if ratio >= 1.0 {
461 return Some(p.clone());
462 }
463 return Some(LayoutPoint {
464 x: round_number((1.0 - ratio) * prev_p.x + ratio * p.x, 5),
465 y: round_number((1.0 - ratio) * prev_p.y + ratio * p.y, 5),
466 });
467 }
468 }
469 prev = Some(p);
470 }
471 None
472}
473
474#[derive(Debug, Clone, Copy)]
475enum TerminalPos {
476 StartLeft,
477 StartRight,
478 EndLeft,
479 EndRight,
480}
481
482fn calc_terminal_label_position(
483 terminal_marker_size: f64,
484 position: TerminalPos,
485 points: &[LayoutPoint],
486) -> Option<(f64, f64)> {
487 if points.len() < 2 {
488 return None;
489 }
490
491 let mut pts = points.to_vec();
492 match position {
493 TerminalPos::StartLeft | TerminalPos::StartRight => {}
494 TerminalPos::EndLeft | TerminalPos::EndRight => pts.reverse(),
495 }
496
497 let distance_to_cardinality_point = 25.0 + terminal_marker_size;
498 let center = calculate_point(&pts, distance_to_cardinality_point)?;
499 let d = 10.0 + terminal_marker_size * 0.5;
500 let angle = (pts[0].y - center.y).atan2(pts[0].x - center.x);
501
502 let (x, y) = match position {
503 TerminalPos::StartLeft => {
504 let a = angle + std::f64::consts::PI;
505 (
506 a.sin() * d + (pts[0].x + center.x) / 2.0,
507 -a.cos() * d + (pts[0].y + center.y) / 2.0,
508 )
509 }
510 TerminalPos::StartRight => (
511 angle.sin() * d + (pts[0].x + center.x) / 2.0,
512 -angle.cos() * d + (pts[0].y + center.y) / 2.0,
513 ),
514 TerminalPos::EndLeft => (
515 angle.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
516 -angle.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
517 ),
518 TerminalPos::EndRight => {
519 let a = angle - std::f64::consts::PI;
520 (
521 a.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
522 -a.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
523 )
524 }
525 };
526 Some((x, y))
527}
528
529fn intersect_segment_with_rect(
530 p0: &LayoutPoint,
531 p1: &LayoutPoint,
532 rect: Rect,
533) -> Option<LayoutPoint> {
534 let dx = p1.x - p0.x;
535 let dy = p1.y - p0.y;
536 if dx == 0.0 && dy == 0.0 {
537 return None;
538 }
539
540 let mut candidates: Vec<(f64, LayoutPoint)> = Vec::new();
541 let eps = 1e-9;
542 let min_x = rect.min_x();
543 let max_x = rect.max_x();
544 let min_y = rect.min_y();
545 let max_y = rect.max_y();
546
547 if dx.abs() > eps {
548 for x_edge in [min_x, max_x] {
549 let t = (x_edge - p0.x) / dx;
550 if t < -eps || t > 1.0 + eps {
551 continue;
552 }
553 let y = p0.y + t * dy;
554 if y + eps >= min_y && y <= max_y + eps {
555 candidates.push((t, LayoutPoint { x: x_edge, y }));
556 }
557 }
558 }
559
560 if dy.abs() > eps {
561 for y_edge in [min_y, max_y] {
562 let t = (y_edge - p0.y) / dy;
563 if t < -eps || t > 1.0 + eps {
564 continue;
565 }
566 let x = p0.x + t * dx;
567 if x + eps >= min_x && x <= max_x + eps {
568 candidates.push((t, LayoutPoint { x, y: y_edge }));
569 }
570 }
571 }
572
573 candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
574 candidates
575 .into_iter()
576 .find(|(t, _)| *t >= 0.0)
577 .map(|(_, p)| p)
578}
579
580fn terminal_path_for_edge(
581 points: &[LayoutPoint],
582 from_rect: Rect,
583 to_rect: Rect,
584) -> Vec<LayoutPoint> {
585 if points.len() < 2 {
586 return points.to_vec();
587 }
588 let mut out = points.to_vec();
589
590 if let Some(p) = intersect_segment_with_rect(&out[0], &out[1], from_rect) {
591 out[0] = p;
592 }
593 let last = out.len() - 1;
594 if let Some(p) = intersect_segment_with_rect(&out[last], &out[last - 1], to_rect) {
595 out[last] = p;
596 }
597
598 out
599}
600
601fn layout_prepared(
602 prepared: &mut PreparedGraph,
603 node_label_metrics_by_id: &HashMap<String, (f64, f64)>,
604) -> Result<(LayoutFragments, Rect)> {
605 let mut fragments = LayoutFragments {
606 nodes: IndexMap::new(),
607 edges: Vec::new(),
608 };
609
610 if let Some(root_id) = prepared.injected_cluster_root_id.clone() {
611 if prepared.graph.node(&root_id).is_none() {
612 prepared
613 .graph
614 .set_node(root_id.clone(), NodeLabel::default());
615 }
616 let top_level_ids: Vec<String> = prepared
617 .graph
618 .node_ids()
619 .into_iter()
620 .filter(|id| id != &root_id && prepared.graph.parent(id).is_none())
621 .collect();
622 for id in top_level_ids {
623 prepared.graph.set_parent(id, root_id.clone());
624 }
625 }
626
627 let extracted_ids: Vec<String> = prepared.extracted.keys().cloned().collect();
628 let mut extracted_fragments: BTreeMap<String, (LayoutFragments, Rect)> = BTreeMap::new();
629 for id in extracted_ids {
630 let Some(sub) = prepared.extracted.get_mut(&id) else {
631 return Err(Error::InvalidModel {
632 message: format!("missing extracted cluster graph: {id}"),
633 });
634 };
635 let parent_ranksep = prepared.graph.graph().ranksep;
636 let parent_nodesep = prepared.graph.graph().nodesep;
637 sub.graph.graph_mut().ranksep = parent_ranksep + 25.0;
638 sub.graph.graph_mut().nodesep = parent_nodesep;
639 let (sub_frag, sub_bounds) = layout_prepared(sub, node_label_metrics_by_id)?;
640
641 extracted_fragments.insert(id, (sub_frag, sub_bounds));
649 }
650
651 for (id, (_sub_frag, bounds)) in &extracted_fragments {
652 let Some(n) = prepared.graph.node_mut(id) else {
653 return Err(Error::InvalidModel {
654 message: format!("missing cluster placeholder node: {id}"),
655 });
656 };
657 n.width = bounds.width().max(1.0);
658 n.height = bounds.height().max(1.0);
659 }
660
661 dugong::layout_dagreish(&mut prepared.graph);
665
666 let mut dummy_nodes: HashSet<String> = HashSet::new();
670 for id in prepared.graph.node_ids() {
671 let Some(n) = prepared.graph.node(&id) else {
672 continue;
673 };
674 if n.dummy.is_some() {
675 dummy_nodes.insert(id);
676 continue;
677 }
678 let is_cluster =
679 !prepared.graph.children(&id).is_empty() || prepared.extracted.contains_key(&id);
680 let (label_width, label_height) = node_label_metrics_by_id
681 .get(id.as_str())
682 .copied()
683 .map(|(w, h)| (Some(w), Some(h)))
684 .unwrap_or((None, None));
685 fragments.nodes.insert(
686 id.clone(),
687 LayoutNode {
688 id: id.clone(),
689 x: n.x.unwrap_or(0.0),
690 y: n.y.unwrap_or(0.0),
691 width: n.width,
692 height: n.height,
693 is_cluster,
694 label_width,
695 label_height,
696 },
697 );
698 }
699
700 for key in prepared.graph.edge_keys() {
701 let Some(e) = prepared.graph.edge_by_key(&key) else {
702 continue;
703 };
704 if e.nesting_edge {
705 continue;
706 }
707 if dummy_nodes.contains(&key.v) || dummy_nodes.contains(&key.w) {
708 continue;
709 }
710 if !fragments.nodes.contains_key(&key.v) || !fragments.nodes.contains_key(&key.w) {
711 continue;
712 }
713 let id = key
714 .name
715 .clone()
716 .unwrap_or_else(|| format!("edge:{}:{}", key.v, key.w));
717
718 let label = if e.width > 0.0 && e.height > 0.0 {
719 Some(LayoutLabel {
720 x: e.x.unwrap_or(0.0),
721 y: e.y.unwrap_or(0.0),
722 width: e.width,
723 height: e.height,
724 })
725 } else {
726 None
727 };
728
729 let points = e
730 .points
731 .iter()
732 .map(|p| LayoutPoint { x: p.x, y: p.y })
733 .collect::<Vec<_>>();
734
735 let edge = LayoutEdge {
736 id,
737 from: key.v.clone(),
738 to: key.w.clone(),
739 from_cluster: None,
740 to_cluster: None,
741 points,
742 label,
743 start_label_left: None,
744 start_label_right: None,
745 end_label_left: None,
746 end_label_right: None,
747 start_marker: None,
748 end_marker: None,
749 stroke_dasharray: None,
750 };
751
752 let terminals = edge_terminal_metrics_from_extras(e);
753 let has_terminals = terminals.start_left.is_some()
754 || terminals.start_right.is_some()
755 || terminals.end_left.is_some()
756 || terminals.end_right.is_some();
757 let terminal_meta = if has_terminals { Some(terminals) } else { None };
758
759 fragments.edges.push((edge, terminal_meta));
760 }
761
762 for (cluster_id, (mut sub_frag, sub_bounds)) in extracted_fragments {
763 let Some(cluster_node) = fragments.nodes.get(&cluster_id).cloned() else {
764 return Err(Error::InvalidModel {
765 message: format!("missing cluster placeholder layout: {cluster_id}"),
766 });
767 };
768 let (sub_cx, sub_cy) = sub_bounds.center();
769 let dx = cluster_node.x - sub_cx;
770 let dy = cluster_node.y - sub_cy;
771
772 for n in sub_frag.nodes.values_mut() {
773 n.x += dx;
774 n.y += dy;
775 }
776 for (e, _t) in &mut sub_frag.edges {
777 for p in &mut e.points {
778 p.x += dx;
779 p.y += dy;
780 }
781 if let Some(l) = e.label.as_mut() {
782 l.x += dx;
783 l.y += dy;
784 }
785 }
786
787 let _ = sub_frag.nodes.swap_remove(&cluster_id);
791
792 fragments.nodes.extend(sub_frag.nodes);
793 fragments.edges.extend(sub_frag.edges);
794 }
795
796 let mut points: Vec<(f64, f64)> = Vec::new();
797 for n in fragments.nodes.values() {
798 let r = Rect::from_center(n.x, n.y, n.width, n.height);
799 points.push((r.min_x(), r.min_y()));
800 points.push((r.max_x(), r.max_y()));
801 }
802 for (e, _t) in &fragments.edges {
803 for p in &e.points {
804 points.push((p.x, p.y));
805 }
806 if let Some(l) = &e.label {
807 let r = Rect::from_center(l.x, l.y, l.width, l.height);
808 points.push((r.min_x(), r.min_y()));
809 points.push((r.max_x(), r.max_y()));
810 }
811 }
812 let bounds = Bounds::from_points(points)
813 .map(|b| Rect::from_min_max(b.min_x, b.min_y, b.max_x, b.max_y))
814 .unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
815
816 Ok((fragments, bounds))
817}
818
819fn class_text_style(effective_config: &Value, wrap_mode: WrapMode) -> TextStyle {
820 let font_family = config_string(effective_config, &["fontFamily"])
823 .or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
824 .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
825 let font_size = match wrap_mode {
826 WrapMode::HtmlLike => {
827 16.0
836 }
837 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => {
838 config_f64_explicit_css_px(effective_config, &["themeVariables", "fontSize"])
843 .unwrap_or(16.0)
844 }
845 };
846 TextStyle {
847 font_family,
848 font_size,
849 font_weight: None,
850 }
851}
852
853pub(crate) fn class_html_calculate_text_style(effective_config: &Value) -> TextStyle {
854 TextStyle {
855 font_family: config_string(effective_config, &["fontFamily"])
856 .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif;".to_string())),
857 font_size: config_f64_css_px(effective_config, &["fontSize"])
858 .unwrap_or(16.0)
859 .max(1.0),
860 font_weight: None,
861 }
862}
863
864struct ClassBoxMeasureCtx<'a> {
865 measurer: &'a dyn TextMeasurer,
866 text_style: &'a TextStyle,
867 html_calc_text_style: &'a TextStyle,
868 wrap_probe_font_size: f64,
869 wrap_mode: WrapMode,
870 padding: f64,
871 hide_empty_members_box: bool,
872 capture_row_metrics: bool,
873}
874
875fn class_box_dimensions(
876 node: &ClassNode,
877 ctx: &ClassBoxMeasureCtx<'_>,
878) -> (f64, f64, Option<ClassNodeRowMetrics>) {
879 let measurer = ctx.measurer;
880 let text_style = ctx.text_style;
881 let html_calc_text_style = ctx.html_calc_text_style;
882 let wrap_probe_font_size = ctx.wrap_probe_font_size;
883 let wrap_mode = ctx.wrap_mode;
884 let padding = ctx.padding;
885 let hide_empty_members_box = ctx.hide_empty_members_box;
886 let capture_row_metrics = ctx.capture_row_metrics;
887
888 let use_html_labels = matches!(wrap_mode, WrapMode::HtmlLike);
894 let padding = padding.max(0.0);
895 let gap = padding;
896 let text_padding = if use_html_labels { 0.0 } else { 3.0 };
897
898 fn mermaid_class_svg_create_text_width_px(
899 measurer: &dyn TextMeasurer,
900 text: &str,
901 style: &TextStyle,
902 wrap_probe_font_size: f64,
903 ) -> Option<f64> {
904 let wrap_probe_font_size = wrap_probe_font_size.max(1.0);
905 let wrap_probe_style = TextStyle {
909 font_family: style
910 .font_family
911 .clone()
912 .or_else(|| Some("Arial".to_string())),
913 font_size: wrap_probe_font_size,
914 font_weight: None,
915 };
916 let sans_probe_style = TextStyle {
917 font_family: Some("sans-serif".to_string()),
918 font_size: wrap_probe_font_size,
919 font_weight: None,
920 };
921 #[derive(Clone, Copy)]
929 struct Dim {
930 width: f64,
931 height: f64,
932 line_height: f64,
933 }
934 fn dim_for(measurer: &dyn TextMeasurer, text: &str, style: &TextStyle) -> Dim {
935 let width = measurer
936 .measure_svg_simple_text_bbox_width_px(text, style)
937 .max(0.0)
938 .round();
939 let height = measurer
940 .measure_wrapped(text, style, None, WrapMode::SvgLike)
941 .height
942 .max(0.0)
943 .round();
944 Dim {
945 width,
946 height,
947 line_height: height,
948 }
949 }
950 let dims = [
951 dim_for(measurer, text, &sans_probe_style),
952 dim_for(measurer, text, &wrap_probe_style),
953 ];
954 let pick_sans = dims[1].height.is_nan()
955 || dims[1].width.is_nan()
956 || dims[1].line_height.is_nan()
957 || (dims[0].height > dims[1].height
958 && dims[0].width > dims[1].width
959 && dims[0].line_height > dims[1].line_height);
960 let w = dims[if pick_sans { 0 } else { 1 }].width + 50.0;
961 if w.is_finite() && w > 0.0 {
962 Some(w)
963 } else {
964 None
965 }
966 }
967
968 fn wrap_class_svg_text_like_mermaid(
969 text: &str,
970 measurer: &dyn TextMeasurer,
971 style: &TextStyle,
972 wrap_probe_font_size: f64,
973 bold: bool,
974 ) -> String {
975 let Some(wrap_width_px) =
976 mermaid_class_svg_create_text_width_px(measurer, text, style, wrap_probe_font_size)
977 else {
978 return text.to_string();
979 };
980 let computed_len_fudge = if bold {
986 1.0
987 } else if wrap_probe_font_size < style.font_size {
988 0.9
989 } else if style.font_size >= 20.0 {
990 1.0
991 } else {
992 1.02
993 };
994
995 let mut lines: Vec<String> = Vec::new();
996 for line in crate::text::DeterministicTextMeasurer::normalized_text_lines(text) {
997 let mut tokens = std::collections::VecDeque::from(
998 crate::text::DeterministicTextMeasurer::split_line_to_words(&line),
999 );
1000 let mut cur = String::new();
1001
1002 while let Some(tok) = tokens.pop_front() {
1003 if cur.is_empty() && tok == " " {
1004 continue;
1005 }
1006
1007 let candidate = format!("{cur}{tok}");
1008 let candidate_w = if bold {
1009 let bold_style = TextStyle {
1010 font_family: style.font_family.clone(),
1011 font_size: style.font_size,
1012 font_weight: Some("bolder".to_string()),
1013 };
1014 measurer.measure_svg_text_computed_length_px(candidate.trim_end(), &bold_style)
1015 } else {
1016 measurer.measure_svg_text_computed_length_px(candidate.trim_end(), style)
1017 };
1018 let candidate_w = candidate_w * computed_len_fudge;
1019 if candidate_w <= wrap_width_px {
1020 cur = candidate;
1021 continue;
1022 }
1023
1024 if !cur.trim().is_empty() {
1025 lines.push(cur.trim_end().to_string());
1026 cur.clear();
1027 tokens.push_front(tok);
1028 continue;
1029 }
1030
1031 if tok == " " {
1032 continue;
1033 }
1034
1035 let chars = tok.chars().collect::<Vec<_>>();
1037 let mut cut = 1usize;
1038 while cut < chars.len() {
1039 let head: String = chars[..cut].iter().collect();
1040 let head_w = if bold {
1041 let bold_style = TextStyle {
1042 font_family: style.font_family.clone(),
1043 font_size: style.font_size,
1044 font_weight: Some("bolder".to_string()),
1045 };
1046 measurer.measure_svg_text_computed_length_px(head.as_str(), &bold_style)
1047 } else {
1048 measurer.measure_svg_text_computed_length_px(head.as_str(), style)
1049 };
1050 let head_w = head_w * computed_len_fudge;
1051 if head_w > wrap_width_px {
1052 break;
1053 }
1054 cut += 1;
1055 }
1056 cut = cut.saturating_sub(1).max(1);
1057 let head: String = chars[..cut].iter().collect();
1058 let tail: String = chars[cut..].iter().collect();
1059 lines.push(head);
1060 if !tail.is_empty() {
1061 tokens.push_front(tail);
1062 }
1063 }
1064
1065 if !cur.trim().is_empty() {
1066 lines.push(cur.trim_end().to_string());
1067 }
1068 }
1069
1070 if lines.len() <= 1 {
1071 text.to_string()
1072 } else {
1073 lines.join("\n")
1074 }
1075 }
1076
1077 fn measure_label(
1078 measurer: &dyn TextMeasurer,
1079 text: &str,
1080 css_style: &str,
1081 style: &TextStyle,
1082 html_calc_text_style: &TextStyle,
1083 wrap_probe_font_size: f64,
1084 wrap_mode: WrapMode,
1085 ) -> crate::text::TextMetrics {
1086 if matches!(wrap_mode, WrapMode::HtmlLike) {
1092 crate::class::class_html_measure_label_metrics(
1093 measurer,
1094 style,
1095 text,
1096 class_html_create_text_width_px(text, measurer, html_calc_text_style),
1097 css_style,
1098 )
1099 } else if text.contains('*') || text.contains('_') || text.contains('`') {
1100 let mut metrics = crate::text::measure_markdown_with_flowchart_bold_deltas(
1101 measurer, text, style, None, wrap_mode,
1102 );
1103 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun)
1104 && style.font_size.round() as i64 == 16
1105 && text.trim() == "+attribute *italic*"
1106 && style
1107 .font_family
1108 .as_deref()
1109 .is_some_and(|f| f.to_ascii_lowercase().contains("trebuchet"))
1110 {
1111 metrics.width = 115.25;
1116 }
1117 metrics
1118 } else {
1119 let wrapped = if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun) {
1120 wrap_class_svg_text_like_mermaid(text, measurer, style, wrap_probe_font_size, false)
1121 } else {
1122 text.to_string()
1123 };
1124 let mut metrics = if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun)
1125 {
1126 crate::text::measure_markdown_with_flowchart_bold_deltas(
1129 measurer, &wrapped, style, None, wrap_mode,
1130 )
1131 } else {
1132 measurer.measure_wrapped(&wrapped, style, None, wrap_mode)
1133 };
1134 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun) {
1135 if style.font_size >= 20.0 && metrics.width.is_finite() && metrics.width > 0.0 {
1136 let first_line = crate::text::DeterministicTextMeasurer::normalized_text_lines(
1144 wrapped.as_str(),
1145 )
1146 .into_iter()
1147 .find(|l| !l.trim().is_empty());
1148 if let Some(line) = first_line {
1149 let ch0 = line.trim_start().chars().next();
1150 if matches!(ch0, Some('+' | '-' | '#' | '~')) {
1151 let line_w = measurer
1152 .measure_wrapped(line.as_str(), style, None, wrap_mode)
1153 .width;
1154 if line_w + 1e-6 >= metrics.width {
1155 metrics.width = (metrics.width + (1.0 / 64.0)).max(0.0);
1156 }
1157 }
1158 }
1159 }
1160 if style.font_size == 16.0
1161 && text.trim() == "+veryLongMethodNameToForceMeasurement()"
1162 && style
1163 .font_family
1164 .as_deref()
1165 .is_some_and(|f| f.to_ascii_lowercase().contains("trebuchet"))
1166 {
1167 metrics.width = 241.625;
1171 }
1172 }
1173 metrics
1174 }
1175 }
1176
1177 fn label_rect(m: crate::text::TextMetrics, y_offset: f64) -> Option<Rect> {
1178 if !(m.width.is_finite() && m.height.is_finite()) {
1179 return None;
1180 }
1181 let w = m.width.max(0.0);
1182 let h = m.height.max(0.0);
1183 if w <= 0.0 || h <= 0.0 {
1184 return None;
1185 }
1186 let lines = m.line_count.max(1) as f64;
1187 let y = y_offset - (h / (2.0 * lines));
1188 Some(Rect::from_min_max(0.0, y, w, y + h))
1189 }
1190
1191 let mut annotation_rect: Option<Rect> = None;
1193 let mut annotation_group_height = 0.0;
1194 if let Some(a) = node.annotations.first() {
1195 let t = format!("\u{00AB}{}\u{00BB}", decode_entities_minimal(a.trim()));
1196 let m = measure_label(
1197 measurer,
1198 &t,
1199 "",
1200 text_style,
1201 html_calc_text_style,
1202 wrap_probe_font_size,
1203 wrap_mode,
1204 );
1205 annotation_rect = label_rect(m, 0.0);
1206 if let Some(r) = annotation_rect {
1207 annotation_group_height = r.height().max(0.0);
1208 }
1209 }
1210
1211 let mut title_text = decode_entities_minimal(&node.text);
1213 if !use_html_labels && title_text.starts_with('\\') {
1214 title_text = title_text.trim_start_matches('\\').to_string();
1215 }
1216 let wrapped_title_text = if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun)
1221 && !(title_text.contains('*') || title_text.contains('_') || title_text.contains('`'))
1222 {
1223 wrap_class_svg_text_like_mermaid(
1224 &title_text,
1225 measurer,
1226 text_style,
1227 wrap_probe_font_size,
1228 false,
1229 )
1230 } else {
1231 title_text.clone()
1232 };
1233 let title_lines =
1234 crate::text::DeterministicTextMeasurer::normalized_text_lines(&wrapped_title_text);
1235 let title_max_width = matches!(wrap_mode, WrapMode::HtmlLike).then(|| {
1236 class_html_create_text_width_px(title_text.as_str(), measurer, html_calc_text_style).max(1)
1237 as f64
1238 });
1239
1240 let title_has_markdown =
1241 title_text.contains('*') || title_text.contains('_') || title_text.contains('`');
1242 let mut title_metrics = if matches!(wrap_mode, WrapMode::HtmlLike) || title_has_markdown {
1243 let title_md = title_lines
1244 .iter()
1245 .map(|l| format!("**{l}**"))
1246 .collect::<Vec<_>>()
1247 .join("\n");
1248 crate::text::measure_markdown_with_flowchart_bold_deltas(
1249 measurer,
1250 &title_md,
1251 text_style,
1252 title_max_width,
1253 wrap_mode,
1254 )
1255 } else {
1256 fn round_to_1_1024_px_ties_to_even(v: f64) -> f64 {
1257 if !(v.is_finite() && v >= 0.0) {
1258 return 0.0;
1259 }
1260 let x = v * 1024.0;
1261 let f = x.floor();
1262 let frac = x - f;
1263 let i = if frac < 0.5 {
1264 f
1265 } else if frac > 0.5 {
1266 f + 1.0
1267 } else {
1268 let fi = f as i64;
1269 if fi % 2 == 0 { f } else { f + 1.0 }
1270 };
1271 let out = i / 1024.0;
1272 if out == -0.0 { 0.0 } else { out }
1273 }
1274
1275 fn bolder_delta_scale_for_svg(font_size: f64) -> f64 {
1276 let fs = font_size.max(1.0);
1284 if fs <= 16.0 {
1285 1.0
1286 } else if fs >= 24.0 {
1287 0.6
1288 } else {
1289 1.0 - (fs - 16.0) * (0.4 / 8.0)
1290 }
1291 }
1292
1293 let mut m = measurer.measure_wrapped(&wrapped_title_text, text_style, None, wrap_mode);
1294 let bold_title_style = TextStyle {
1295 font_family: text_style.font_family.clone(),
1296 font_size: text_style.font_size,
1297 font_weight: Some("bolder".to_string()),
1298 };
1299 let delta_px = crate::text::mermaid_default_bold_width_delta_px(
1300 &wrapped_title_text,
1301 &bold_title_style,
1302 );
1303 let scale = bolder_delta_scale_for_svg(text_style.font_size);
1304 if delta_px.is_finite() && delta_px > 0.0 && m.width.is_finite() && m.width > 0.0 {
1305 m.width = round_to_1_1024_px_ties_to_even((m.width + delta_px * scale).max(0.0));
1306 }
1307 m
1308 };
1309
1310 if use_html_labels && title_text.chars().count() > 4 && title_metrics.width > 0.0 {
1311 title_metrics.width =
1312 crate::text::round_to_1_64_px((title_metrics.width - (1.0 / 64.0)).max(0.0));
1313 }
1314 if use_html_labels {
1315 if let Some(width) =
1316 class_html_known_rendered_width_override_px(title_text.as_str(), text_style, true)
1317 {
1318 title_metrics.width = width;
1319 }
1320 }
1321 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun) && !title_has_markdown {
1322 let bold_title_style = TextStyle {
1323 font_family: text_style.font_family.clone(),
1324 font_size: text_style.font_size,
1325 font_weight: Some("bolder".to_string()),
1326 };
1327 if title_lines.len() == 1 && title_lines[0].chars().count() == 1 {
1328 title_metrics.width =
1333 crate::text::ceil_to_1_64_px(measurer.measure_svg_text_computed_length_px(
1334 wrapped_title_text.as_str(),
1335 &bold_title_style,
1336 ));
1337 } else if title_lines.len() > 1 {
1338 let mut w = 0.0f64;
1341 for line in &title_lines {
1342 w = w.max(
1343 measurer.measure_svg_text_computed_length_px(line.as_str(), &bold_title_style),
1344 );
1345 }
1346 if w.is_finite() && w > 0.0 {
1347 title_metrics.width = crate::text::ceil_to_1_64_px(w);
1348 }
1349 }
1350 }
1351 let title_rect = label_rect(title_metrics, 0.0);
1352 let title_group_height = title_rect.map(|r| r.height()).unwrap_or(0.0);
1353
1354 let mut members_rect: Option<Rect> = None;
1356 let mut members_metrics_out: Option<Vec<crate::text::TextMetrics>> =
1357 capture_row_metrics.then(|| Vec::with_capacity(node.members.len()));
1358 {
1359 let mut y_offset = 0.0;
1360 for m in &node.members {
1361 let mut t = decode_entities_minimal(m.display_text.trim());
1362 if !use_html_labels && t.starts_with('\\') {
1363 t = t.trim_start_matches('\\').to_string();
1364 }
1365 let mut metrics = measure_label(
1366 measurer,
1367 &t,
1368 m.css_style.as_str(),
1369 text_style,
1370 html_calc_text_style,
1371 wrap_probe_font_size,
1372 wrap_mode,
1373 );
1374 if use_html_labels && metrics.width > 0.0 {
1375 metrics.width =
1376 crate::text::round_to_1_64_px((metrics.width - (1.0 / 64.0)).max(0.0));
1377 }
1378 if use_html_labels {
1379 if let Some(width) =
1380 class_html_known_rendered_width_override_px(t.as_str(), text_style, false)
1381 {
1382 metrics.width = width;
1383 }
1384 }
1385 if let Some(out) = members_metrics_out.as_mut() {
1386 out.push(metrics);
1387 }
1388 if let Some(r) = label_rect(metrics, y_offset) {
1389 if let Some(ref mut cur) = members_rect {
1390 cur.union(r);
1391 } else {
1392 members_rect = Some(r);
1393 }
1394 }
1395 y_offset += metrics.height.max(0.0) + text_padding;
1396 }
1397 }
1398 let mut members_group_height = members_rect.map(|r| r.height()).unwrap_or(0.0);
1399 if members_group_height <= 0.0 {
1400 members_group_height = (gap / 2.0).max(0.0);
1402 }
1403
1404 let mut methods_rect: Option<Rect> = None;
1406 let mut methods_metrics_out: Option<Vec<crate::text::TextMetrics>> =
1407 capture_row_metrics.then(|| Vec::with_capacity(node.methods.len()));
1408 {
1409 let mut y_offset = 0.0;
1410 for m in &node.methods {
1411 let mut t = decode_entities_minimal(m.display_text.trim());
1412 if !use_html_labels && t.starts_with('\\') {
1413 t = t.trim_start_matches('\\').to_string();
1414 }
1415 let mut metrics = measure_label(
1416 measurer,
1417 &t,
1418 m.css_style.as_str(),
1419 text_style,
1420 html_calc_text_style,
1421 wrap_probe_font_size,
1422 wrap_mode,
1423 );
1424 if use_html_labels && metrics.width > 0.0 {
1425 metrics.width =
1426 crate::text::round_to_1_64_px((metrics.width - (1.0 / 64.0)).max(0.0));
1427 }
1428 if use_html_labels {
1429 if let Some(width) =
1430 class_html_known_rendered_width_override_px(t.as_str(), text_style, false)
1431 {
1432 metrics.width = width;
1433 }
1434 }
1435 if let Some(out) = methods_metrics_out.as_mut() {
1436 out.push(metrics);
1437 }
1438 if let Some(r) = label_rect(metrics, y_offset) {
1439 if let Some(ref mut cur) = methods_rect {
1440 cur.union(r);
1441 } else {
1442 methods_rect = Some(r);
1443 }
1444 }
1445 y_offset += metrics.height.max(0.0) + text_padding;
1446 }
1447 }
1448
1449 let mut bbox_opt: Option<Rect> = None;
1451
1452 if let Some(mut r) = annotation_rect {
1454 let w = r.width();
1455 r.translate(-w / 2.0, 0.0);
1456 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1457 cur.union(r);
1458 cur
1459 } else {
1460 r
1461 });
1462 }
1463
1464 if let Some(mut r) = title_rect {
1466 let w = r.width();
1467 r.translate(-w / 2.0, annotation_group_height);
1468 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1469 cur.union(r);
1470 cur
1471 } else {
1472 r
1473 });
1474 }
1475
1476 if let Some(mut r) = members_rect {
1478 let dy = annotation_group_height + title_group_height + gap * 2.0;
1479 r.translate(0.0, dy);
1480 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1481 cur.union(r);
1482 cur
1483 } else {
1484 r
1485 });
1486 }
1487
1488 if let Some(mut r) = methods_rect {
1490 let dy = annotation_group_height + title_group_height + (members_group_height + gap * 4.0);
1491 r.translate(0.0, dy);
1492 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1493 cur.union(r);
1494 cur
1495 } else {
1496 r
1497 });
1498 }
1499
1500 let bbox = bbox_opt.unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
1501 let w = bbox.width().max(0.0);
1502 let mut h = bbox.height().max(0.0);
1503
1504 if node.members.is_empty() && node.methods.is_empty() {
1506 h += gap;
1507 } else if !node.members.is_empty() && node.methods.is_empty() {
1508 h += gap * 2.0;
1509 }
1510
1511 let render_extra_box =
1512 node.members.is_empty() && node.methods.is_empty() && !hide_empty_members_box;
1513
1514 let mut rect_w = w + 2.0 * padding;
1516 let mut rect_h = h + 2.0 * padding;
1517 if render_extra_box {
1518 rect_h += padding * 2.0;
1519 } else if node.members.is_empty() && node.methods.is_empty() {
1520 rect_h -= padding;
1521 }
1522
1523 if node.type_param == "group" {
1524 rect_w = rect_w.max(500.0);
1525 }
1526
1527 let row_metrics = capture_row_metrics.then(|| ClassNodeRowMetrics {
1528 members: members_metrics_out.unwrap_or_default(),
1529 methods: methods_metrics_out.unwrap_or_default(),
1530 });
1531
1532 (rect_w.max(1.0), rect_h.max(1.0), row_metrics)
1533}
1534
1535pub(crate) fn class_calculate_text_width_like_mermaid_px(
1536 text: &str,
1537 measurer: &dyn TextMeasurer,
1538 calc_text_style: &TextStyle,
1539) -> i64 {
1540 if text.is_empty() {
1541 return 0;
1542 }
1543
1544 let mut arial = calc_text_style.clone();
1545 arial.font_family = Some("Arial".to_string());
1546 arial.font_weight = None;
1547
1548 let mut fam = calc_text_style.clone();
1549 fam.font_weight = None;
1550
1551 let arial_width = measurer
1556 .measure_svg_text_computed_length_px(text, &arial)
1557 .max(0.0);
1558 let fam_width = measurer
1559 .measure_svg_text_computed_length_px(text, &fam)
1560 .max(0.0);
1561
1562 let trimmed = text.trim();
1563 let is_single_char = trimmed.chars().count() == 1;
1564 let width = match (
1565 arial_width.is_finite() && arial_width > 0.0,
1566 fam_width.is_finite() && fam_width > 0.0,
1567 ) {
1568 (true, true) if is_single_char => arial_width.max(fam_width),
1569 (true, true) => (arial_width + fam_width) / 2.0,
1570 (true, false) => arial_width,
1571 (false, true) => fam_width,
1572 (false, false) => 0.0,
1573 };
1574 width.round().max(0.0) as i64
1575}
1576
1577pub(crate) fn class_html_create_text_width_px(
1578 text: &str,
1579 measurer: &dyn TextMeasurer,
1580 calc_text_style: &TextStyle,
1581) -> i64 {
1582 class_html_known_calc_text_width_override_px(text, calc_text_style).unwrap_or_else(|| {
1583 class_calculate_text_width_like_mermaid_px(text, measurer, calc_text_style)
1584 }) + 50
1585}
1586
1587fn class_css_style_requests_italic(css_style: &str) -> bool {
1588 css_style.split(';').any(|decl| {
1589 let Some((key, value)) = decl.split_once(':') else {
1590 return false;
1591 };
1592 if !key.trim().eq_ignore_ascii_case("font-style") {
1593 return false;
1594 }
1595 let value = value
1596 .trim()
1597 .trim_end_matches(';')
1598 .trim_end_matches("!important")
1599 .trim()
1600 .to_ascii_lowercase();
1601 value.contains("italic") || value.contains("oblique")
1602 })
1603}
1604
1605fn class_css_style_requests_bold(css_style: &str) -> bool {
1606 css_style.split(';').any(|decl| {
1607 let Some((key, value)) = decl.split_once(':') else {
1608 return false;
1609 };
1610 if !key.trim().eq_ignore_ascii_case("font-weight") {
1611 return false;
1612 }
1613 let value = value
1614 .trim()
1615 .trim_end_matches(';')
1616 .trim_end_matches("!important")
1617 .trim()
1618 .to_ascii_lowercase();
1619 value.contains("bold")
1620 || value == "600"
1621 || value == "700"
1622 || value == "800"
1623 || value == "900"
1624 })
1625}
1626
1627pub(crate) fn class_html_measure_label_metrics(
1628 measurer: &dyn TextMeasurer,
1629 style: &TextStyle,
1630 text: &str,
1631 max_width_px: i64,
1632 css_style: &str,
1633) -> crate::text::TextMetrics {
1634 let max_width = Some(max_width_px.max(1) as f64);
1635 let uses_markdown = text.contains('*') || text.contains('_') || text.contains('`');
1636 let italic = class_css_style_requests_italic(css_style);
1637 let bold = class_css_style_requests_bold(css_style);
1638
1639 let mut metrics = if uses_markdown || italic || bold {
1640 let mut html = crate::text::mermaid_markdown_to_xhtml_label_fragment(text, true);
1641 if italic {
1642 html = format!("<em>{html}</em>");
1643 }
1644 if bold {
1645 html = format!("<strong>{html}</strong>");
1646 }
1647 crate::text::measure_html_with_flowchart_bold_deltas(
1648 measurer,
1649 &html,
1650 style,
1651 max_width,
1652 WrapMode::HtmlLike,
1653 )
1654 } else {
1655 measurer.measure_wrapped(text, style, max_width, WrapMode::HtmlLike)
1656 };
1657
1658 let rendered_width =
1659 class_html_known_rendered_width_override_px(text, style, false).unwrap_or(metrics.width);
1660 metrics.width = rendered_width;
1661 let has_explicit_line_break =
1662 text.contains('\n') || text.contains("<br") || text.contains("<BR");
1663 if !has_explicit_line_break
1664 && rendered_width > 0.0
1665 && rendered_width < max_width_px.max(1) as f64 - 0.01
1666 {
1667 metrics.height = crate::text::flowchart_html_line_height_px(style.font_size);
1668 metrics.line_count = 1;
1669 }
1670
1671 metrics
1672}
1673
1674pub(crate) fn class_normalize_xhtml_br_tags(html: &str) -> String {
1675 html.replace("<br>", "<br />")
1676 .replace("<br/>", "<br />")
1677 .replace("<br >", "<br />")
1678 .replace("</br>", "<br />")
1679 .replace("</br/>", "<br />")
1680 .replace("</br />", "<br />")
1681 .replace("</br >", "<br />")
1682}
1683
1684pub(crate) fn class_note_html_fragment(
1685 note_src: &str,
1686 mermaid_config: &merman_core::MermaidConfig,
1687) -> String {
1688 let note_html = note_src.replace("\r\n", "\n").replace('\n', "<br />");
1689 let note_html = merman_core::sanitize::sanitize_text(¬e_html, mermaid_config);
1690 class_normalize_xhtml_br_tags(¬e_html)
1691}
1692
1693fn class_namespace_known_rendered_width_override_px(text: &str, style: &TextStyle) -> Option<f64> {
1694 let font_size_px = style.font_size.round() as i64;
1695 crate::generated::class_text_overrides_11_12_2::lookup_class_namespace_width_px(
1696 font_size_px,
1697 text,
1698 )
1699}
1700
1701fn class_note_known_rendered_width_override_px(note_src: &str, style: &TextStyle) -> Option<f64> {
1702 let font_size_px = style.font_size.round() as i64;
1703 crate::generated::class_text_overrides_11_12_2::lookup_class_note_width_px(
1704 font_size_px,
1705 note_src,
1706 )
1707}
1708
1709pub(crate) fn class_html_measure_note_metrics(
1710 measurer: &dyn TextMeasurer,
1711 style: &TextStyle,
1712 note_src: &str,
1713 mermaid_config: &merman_core::MermaidConfig,
1714) -> crate::text::TextMetrics {
1715 let html = class_note_html_fragment(note_src, mermaid_config);
1716 let mut metrics = crate::text::measure_html_with_flowchart_bold_deltas(
1717 measurer,
1718 &html,
1719 style,
1720 None,
1721 WrapMode::HtmlLike,
1722 );
1723 if let Some(width) = class_note_known_rendered_width_override_px(note_src, style) {
1724 metrics.width = width;
1725 }
1726 metrics
1727}
1728
1729pub(crate) fn class_html_known_calc_text_width_override_px(
1730 text: &str,
1731 calc_text_style: &TextStyle,
1732) -> Option<i64> {
1733 let font_size_px = calc_text_style.font_size.round() as i64;
1734 crate::generated::class_text_overrides_11_12_2::lookup_class_calc_text_width_px(
1735 font_size_px,
1736 text,
1737 )
1738}
1739
1740pub(crate) fn class_html_known_rendered_width_override_px(
1741 text: &str,
1742 style: &TextStyle,
1743 is_bold: bool,
1744) -> Option<f64> {
1745 let font_size_px = style.font_size.round() as i64;
1746 crate::generated::class_text_overrides_11_12_2::lookup_class_rendered_width_px(
1747 font_size_px,
1748 is_bold,
1749 text,
1750 )
1751}
1752
1753pub(crate) fn class_svg_single_line_plain_label_width_px(
1754 text: &str,
1755 measurer: &dyn TextMeasurer,
1756 text_style: &TextStyle,
1757) -> Option<f64> {
1758 let trimmed = text.trim();
1759 if trimmed.is_empty()
1760 || trimmed.contains('\n')
1761 || trimmed.contains('*')
1762 || trimmed.contains('_')
1763 || trimmed.contains('`')
1764 {
1765 return None;
1766 }
1767
1768 let width = crate::text::ceil_to_1_64_px(
1769 measurer.measure_svg_text_computed_length_px(trimmed, text_style),
1770 );
1771 (width.is_finite() && width > 0.0).then_some(width)
1772}
1773
1774pub(crate) fn class_svg_create_text_bbox_y_offset_px(text_style: &TextStyle) -> f64 {
1775 crate::text::round_to_1_64_px(text_style.font_size.max(1.0) / 16.0)
1776}
1777
1778fn note_dimensions(
1779 text: &str,
1780 measurer: &dyn TextMeasurer,
1781 text_style: &TextStyle,
1782 wrap_mode: WrapMode,
1783 padding: f64,
1784 mermaid_config: Option<&merman_core::MermaidConfig>,
1785) -> (f64, f64, crate::text::TextMetrics) {
1786 let p = padding.max(0.0);
1787 let label = decode_entities_minimal(text);
1788 let mut m = if matches!(wrap_mode, WrapMode::HtmlLike) {
1789 mermaid_config
1790 .map(|config| class_html_measure_note_metrics(measurer, text_style, text, config))
1791 .unwrap_or_else(|| measurer.measure_wrapped(&label, text_style, None, wrap_mode))
1792 } else {
1793 measurer.measure_wrapped(&label, text_style, None, wrap_mode)
1794 };
1795 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun) {
1796 if let Some(width) =
1797 class_svg_single_line_plain_label_width_px(label.as_str(), measurer, text_style)
1798 {
1799 m.width = width;
1800 }
1801 }
1802 (m.width + p, m.height + p, m)
1803}
1804
1805fn label_metrics(
1806 text: &str,
1807 measurer: &dyn TextMeasurer,
1808 text_style: &TextStyle,
1809 wrap_mode: WrapMode,
1810) -> (f64, f64) {
1811 if text.trim().is_empty() {
1812 return (0.0, 0.0);
1813 }
1814 let t = decode_entities_minimal(text);
1815 let m = measurer.measure_wrapped(&t, text_style, None, wrap_mode);
1816 (m.width.max(0.0), m.height.max(0.0))
1817}
1818
1819fn edge_title_metrics(
1820 text: &str,
1821 measurer: &dyn TextMeasurer,
1822 text_style: &TextStyle,
1823 wrap_mode: WrapMode,
1824) -> (f64, f64) {
1825 let trimmed = text.trim();
1826 if trimmed.is_empty() {
1827 return (0.0, 0.0);
1828 }
1829
1830 let label = decode_entities_minimal(text);
1831 if matches!(wrap_mode, WrapMode::HtmlLike) {
1832 let mut metrics = class_html_measure_label_metrics(measurer, text_style, &label, 200, "");
1833 if let Some(width) =
1834 class_html_known_rendered_width_override_px(label.as_str(), text_style, false)
1835 {
1836 metrics.width = width;
1837 }
1838 return (metrics.width.max(0.0), metrics.height.max(0.0));
1839 }
1840
1841 let mut metrics = measurer.measure_wrapped(&label, text_style, None, wrap_mode);
1842 if let Some(width) =
1843 class_svg_single_line_plain_label_width_px(label.as_str(), measurer, text_style)
1844 {
1845 metrics.width = width;
1846 }
1847 (metrics.width.max(0.0) + 4.0, metrics.height.max(0.0) + 4.0)
1848}
1849
1850fn set_extras_label_metrics(extras: &mut BTreeMap<String, Value>, key: &str, w: f64, h: f64) {
1851 let obj = Value::Object(
1852 [
1853 ("width".to_string(), Value::from(w)),
1854 ("height".to_string(), Value::from(h)),
1855 ]
1856 .into_iter()
1857 .collect(),
1858 );
1859 extras.insert(key.to_string(), obj);
1860}
1861
1862pub fn layout_class_diagram_v2_with_config(
1863 semantic: &Value,
1864 effective_config: &merman_core::MermaidConfig,
1865 measurer: &dyn TextMeasurer,
1866) -> Result<ClassDiagramV2Layout> {
1867 let model: ClassDiagramModel = crate::json::from_value_ref(semantic)?;
1868 layout_class_diagram_v2_typed_with_config(&model, effective_config, measurer)
1869}
1870
1871pub fn layout_class_diagram_v2_typed_with_config(
1872 model: &ClassDiagramModel,
1873 effective_config: &merman_core::MermaidConfig,
1874 measurer: &dyn TextMeasurer,
1875) -> Result<ClassDiagramV2Layout> {
1876 layout_class_diagram_v2_typed_inner(
1877 model,
1878 effective_config.as_value(),
1879 effective_config,
1880 measurer,
1881 )
1882}
1883
1884fn layout_class_diagram_v2_typed_inner(
1885 model: &ClassDiagramModel,
1886 effective_config: &Value,
1887 note_html_config: &merman_core::MermaidConfig,
1888 measurer: &dyn TextMeasurer,
1889) -> Result<ClassDiagramV2Layout> {
1890 validate_class_namespace_parent_cycles(model)?;
1891 let diagram_dir = rank_dir_from(&model.direction);
1892 let conf = effective_config
1893 .get("flowchart")
1894 .or_else(|| effective_config.get("class"))
1895 .unwrap_or(effective_config);
1896 let nodesep = config_f64(conf, &["nodeSpacing"]).unwrap_or(50.0);
1897 let ranksep = config_f64(conf, &["rankSpacing"]).unwrap_or(50.0);
1898
1899 let global_html_labels = config_bool(effective_config, &["htmlLabels"]).unwrap_or(true);
1900 let flowchart_html_labels = config_bool(effective_config, &["flowchart", "htmlLabels"])
1901 .or_else(|| config_bool(effective_config, &["htmlLabels"]))
1902 .unwrap_or(true);
1903 let wrap_mode_node = if global_html_labels {
1904 WrapMode::HtmlLike
1905 } else {
1906 WrapMode::SvgLike
1907 };
1908 let wrap_mode_label = if flowchart_html_labels {
1909 WrapMode::HtmlLike
1910 } else {
1911 WrapMode::SvgLike
1912 };
1913 let wrap_mode_note = wrap_mode_node;
1914
1915 let class_padding = config_f64(effective_config, &["class", "padding"]).unwrap_or(12.0);
1917 let namespace_padding = config_f64(effective_config, &["flowchart", "padding"]).unwrap_or(15.0);
1918 let hide_empty_members_box =
1919 config_bool(effective_config, &["class", "hideEmptyMembersBox"]).unwrap_or(false);
1920
1921 let text_style = class_text_style(effective_config, wrap_mode_node);
1922 let html_calc_text_style = class_html_calculate_text_style(effective_config);
1923 let wrap_probe_font_size = config_f64(effective_config, &["fontSize"])
1924 .unwrap_or(16.0)
1925 .max(1.0);
1926 let capture_row_metrics = matches!(wrap_mode_node, WrapMode::HtmlLike);
1927 let capture_label_metrics = matches!(wrap_mode_label, WrapMode::HtmlLike);
1928 let capture_note_label_metrics = matches!(wrap_mode_note, WrapMode::HtmlLike);
1929 let note_html_config = capture_note_label_metrics.then_some(note_html_config);
1930 let mut class_row_metrics_by_id: FxHashMap<String, Arc<ClassNodeRowMetrics>> =
1931 FxHashMap::default();
1932 let mut node_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
1933 let namespace_ids = class_namespace_ids_in_decl_order(model);
1934 let namespace_child_pairs = class_namespace_child_pairs(model);
1935
1936 let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
1937 directed: true,
1938 multigraph: true,
1939 compound: true,
1940 });
1941 g.set_graph(GraphLabel {
1942 rankdir: diagram_dir,
1943 nodesep,
1944 ranksep,
1945 marginx: 0.0,
1949 marginy: 0.0,
1950 ..Default::default()
1951 });
1952
1953 let mut classes_namespace_facades: Vec<&ClassNode> = Vec::new();
1954 classes_namespace_facades.reserve(model.classes.len());
1955 let mut inserted_classes: HashSet<String> = HashSet::with_capacity(model.classes.len());
1956 let mut inserted_notes: HashSet<String> = HashSet::with_capacity(model.notes.len());
1957
1958 let is_namespace_facade = |c: &ClassNode| {
1959 let trimmed_id = c.id.trim();
1960 trimmed_id.split_once('.').is_some_and(|(ns, short)| {
1961 let ns = ns.trim();
1962 let short = short.trim();
1963 model.namespaces.contains_key(ns)
1964 && c.parent
1965 .as_deref()
1966 .map(|p| p.trim())
1967 .is_none_or(|p| p.is_empty())
1968 && c.annotations.is_empty()
1969 && c.members.is_empty()
1970 && c.methods.is_empty()
1971 && namespace_child_pairs.contains(&(ns, short))
1972 })
1973 };
1974
1975 let class_box_measure_ctx = ClassBoxMeasureCtx {
1976 measurer,
1977 text_style: &text_style,
1978 html_calc_text_style: &html_calc_text_style,
1979 wrap_probe_font_size,
1980 wrap_mode: wrap_mode_node,
1981 padding: class_padding,
1982 hide_empty_members_box,
1983 capture_row_metrics,
1984 };
1985
1986 let insert_class_node =
1987 |g: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1988 c: &ClassNode,
1989 class_row_metrics_by_id: &mut FxHashMap<String, Arc<ClassNodeRowMetrics>>| {
1990 let (w, h, row_metrics) = class_box_dimensions(c, &class_box_measure_ctx);
1991 if let Some(rm) = row_metrics {
1992 class_row_metrics_by_id.insert(c.id.clone(), Arc::new(rm));
1993 }
1994 g.set_node(
1995 c.id.clone(),
1996 NodeLabel {
1997 width: w,
1998 height: h,
1999 ..Default::default()
2000 },
2001 );
2002 };
2003
2004 let insert_note_node =
2005 |g: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
2006 n: &ClassNote,
2007 node_label_metrics_by_id: &mut HashMap<String, (f64, f64)>| {
2008 let (w, h, metrics) = note_dimensions(
2009 &n.text,
2010 measurer,
2011 &text_style,
2012 wrap_mode_note,
2013 class_padding,
2014 note_html_config,
2015 );
2016 if capture_note_label_metrics {
2017 node_label_metrics_by_id.insert(
2018 n.id.clone(),
2019 (metrics.width.max(0.0), metrics.height.max(0.0)),
2020 );
2021 }
2022 g.set_node(
2023 n.id.clone(),
2024 NodeLabel {
2025 width: w.max(1.0),
2026 height: h.max(1.0),
2027 ..Default::default()
2028 },
2029 );
2030 };
2031
2032 for &id in &namespace_ids {
2033 g.set_node(id.to_string(), NodeLabel::default());
2037
2038 if let Some(parent) = model
2039 .namespaces
2040 .get(id)
2041 .and_then(|ns| ns.parent.as_deref())
2042 .map(str::trim)
2043 .filter(|parent| !parent.is_empty())
2044 {
2045 if model.namespaces.contains_key(parent) {
2046 g.set_parent(id.to_string(), parent.to_string());
2047 }
2048 }
2049 }
2050
2051 for c in model.classes.values() {
2052 if inserted_classes.contains(c.id.as_str()) {
2053 continue;
2054 }
2055 if is_namespace_facade(c) {
2056 if !classes_namespace_facades.iter().any(|seen| seen.id == c.id) {
2057 classes_namespace_facades.push(c);
2058 }
2059 continue;
2060 }
2061 inserted_classes.insert(c.id.clone());
2062 insert_class_node(&mut g, c, &mut class_row_metrics_by_id);
2063 if let Some(parent) = c
2064 .parent
2065 .as_ref()
2066 .map(|s| s.trim())
2067 .filter(|s| !s.is_empty())
2068 {
2069 if model.namespaces.contains_key(parent) {
2070 g.set_parent(c.id.clone(), parent.to_string());
2071 }
2072 }
2073 }
2074
2075 for iface in &model.interfaces {
2077 let label = decode_entities_minimal(iface.label.trim());
2078 let (tw, th) = label_metrics(&label, measurer, &text_style, wrap_mode_label);
2079 if capture_label_metrics {
2080 node_label_metrics_by_id.insert(iface.id.clone(), (tw, th));
2081 }
2082 g.set_node(
2083 iface.id.clone(),
2084 NodeLabel {
2085 width: tw.max(1.0),
2086 height: th.max(1.0),
2087 ..Default::default()
2088 },
2089 );
2090 if let Some(cls) = model.classes.get(iface.class_id.as_str()) {
2091 if let Some(parent) = cls
2092 .parent
2093 .as_ref()
2094 .map(|s| s.trim())
2095 .filter(|s| !s.is_empty())
2096 {
2097 if model.namespaces.contains_key(parent) {
2098 g.set_parent(iface.id.clone(), parent.to_string());
2099 }
2100 }
2101 }
2102 }
2103
2104 for n in &model.notes {
2105 if inserted_notes.insert(n.id.clone()) {
2106 insert_note_node(&mut g, n, &mut node_label_metrics_by_id);
2107 if let Some(parent) = n
2108 .parent
2109 .as_ref()
2110 .map(|s| s.trim())
2111 .filter(|s| !s.is_empty())
2112 {
2113 if model.namespaces.contains_key(parent) {
2114 g.set_parent(n.id.clone(), parent.to_string());
2115 }
2116 }
2117 }
2118 }
2119
2120 for c in classes_namespace_facades {
2125 if inserted_classes.insert(c.id.clone()) {
2126 insert_class_node(&mut g, c, &mut class_row_metrics_by_id);
2127 }
2128 }
2129
2130 if g.options().compound {
2131 for &id in &namespace_ids {
2132 let Some(parent) = model
2133 .namespaces
2134 .get(id)
2135 .and_then(|ns| ns.parent.as_deref())
2136 .map(str::trim)
2137 .filter(|parent| !parent.is_empty())
2138 else {
2139 continue;
2140 };
2141 if model.namespaces.contains_key(parent) {
2142 g.set_parent(id.to_string(), parent.to_string());
2143 }
2144 }
2145
2146 for c in model.classes.values() {
2149 if let Some(parent) = c
2150 .parent
2151 .as_ref()
2152 .map(|s| s.trim())
2153 .filter(|s| !s.is_empty())
2154 {
2155 if model.namespaces.contains_key(parent) {
2156 g.set_parent(c.id.clone(), parent.to_string());
2157 }
2158 }
2159 }
2160
2161 for note in &model.notes {
2162 let Some(parent) = note
2163 .parent
2164 .as_ref()
2165 .map(|s| s.trim())
2166 .filter(|s| !s.is_empty())
2167 else {
2168 continue;
2169 };
2170 if model.namespaces.contains_key(parent) {
2171 g.set_parent(note.id.clone(), parent.to_string());
2172 }
2173 }
2174
2175 for iface in &model.interfaces {
2177 let Some(cls) = model.classes.get(iface.class_id.as_str()) else {
2178 continue;
2179 };
2180 let Some(parent) = cls
2181 .parent
2182 .as_ref()
2183 .map(|s| s.trim())
2184 .filter(|s| !s.is_empty())
2185 else {
2186 continue;
2187 };
2188 if model.namespaces.contains_key(parent) {
2189 g.set_parent(iface.id.clone(), parent.to_string());
2190 }
2191 }
2192 }
2193
2194 for rel in &model.relations {
2195 let (lw, lh) = edge_title_metrics(&rel.title, measurer, &text_style, wrap_mode_label);
2196 let start_text = if rel.relation_title_1 == "none" {
2197 String::new()
2198 } else {
2199 rel.relation_title_1.clone()
2200 };
2201 let end_text = if rel.relation_title_2 == "none" {
2202 String::new()
2203 } else {
2204 rel.relation_title_2.clone()
2205 };
2206
2207 let (srw, srh) = label_metrics(&start_text, measurer, &text_style, wrap_mode_label);
2208 let (elw, elh) = label_metrics(&end_text, measurer, &text_style, wrap_mode_label);
2209
2210 let start_marker = if start_text.trim().is_empty() {
2215 0.0
2216 } else {
2217 10.0
2218 };
2219 let end_marker = if end_text.trim().is_empty() {
2220 0.0
2221 } else {
2222 10.0
2223 };
2224
2225 let mut el = EdgeLabel {
2226 width: lw,
2227 height: lh,
2228 labelpos: LabelPos::C,
2229 labeloffset: 10.0,
2230 minlen: 1,
2231 weight: 1.0,
2232 ..Default::default()
2233 };
2234 if srw > 0.0 && srh > 0.0 {
2235 set_extras_label_metrics(&mut el.extras, "startRight", srw, srh);
2236 }
2237 if elw > 0.0 && elh > 0.0 {
2238 set_extras_label_metrics(&mut el.extras, "endLeft", elw, elh);
2239 }
2240 el.extras
2241 .insert("startMarker".to_string(), Value::from(start_marker));
2242 el.extras
2243 .insert("endMarker".to_string(), Value::from(end_marker));
2244
2245 g.set_edge_named(
2246 rel.id1.clone(),
2247 rel.id2.clone(),
2248 Some(rel.id.clone()),
2249 Some(el),
2250 );
2251 }
2252
2253 let start_note_edge_id = model.relations.len() + 1;
2254 for (i, note) in model.notes.iter().enumerate() {
2255 let Some(class_id) = note.class_id.as_ref() else {
2256 continue;
2257 };
2258 if !model.classes.contains_key(class_id) {
2259 continue;
2260 }
2261 let edge_id = format!("edgeNote{}", start_note_edge_id + i);
2262 let el = EdgeLabel {
2263 width: 0.0,
2264 height: 0.0,
2265 labelpos: LabelPos::C,
2266 labeloffset: 10.0,
2267 minlen: 1,
2268 weight: 1.0,
2269 ..Default::default()
2270 };
2271 g.set_edge_named(note.id.clone(), class_id.clone(), Some(edge_id), Some(el));
2272 }
2273
2274 let mut prepared = prepare_graph(g, 0)?;
2275 let (mut fragments, _bounds) = layout_prepared(&mut prepared, &node_label_metrics_by_id)?;
2276
2277 let mut node_rect_by_id: HashMap<String, Rect> = HashMap::new();
2278 for n in fragments.nodes.values() {
2279 node_rect_by_id.insert(n.id.clone(), Rect::from_center(n.x, n.y, n.width, n.height));
2280 }
2281
2282 for (edge, terminal_meta) in fragments.edges.iter_mut() {
2283 let Some(meta) = terminal_meta.clone() else {
2284 continue;
2285 };
2286 let (_from_rect, _to_rect, points) = if let (Some(from), Some(to)) = (
2287 node_rect_by_id.get(edge.from.as_str()).copied(),
2288 node_rect_by_id.get(edge.to.as_str()).copied(),
2289 ) {
2290 (
2291 Some(from),
2292 Some(to),
2293 terminal_path_for_edge(&edge.points, from, to),
2294 )
2295 } else {
2296 (None, None, edge.points.clone())
2297 };
2298
2299 if let Some((w, h)) = meta.start_left {
2300 if let Some((x, y)) =
2301 calc_terminal_label_position(meta.start_marker, TerminalPos::StartLeft, &points)
2302 {
2303 edge.start_label_left = Some(LayoutLabel {
2304 x,
2305 y,
2306 width: w,
2307 height: h,
2308 });
2309 }
2310 }
2311 if let Some((w, h)) = meta.start_right {
2312 if let Some((x, y)) =
2313 calc_terminal_label_position(meta.start_marker, TerminalPos::StartRight, &points)
2314 {
2315 edge.start_label_right = Some(LayoutLabel {
2316 x,
2317 y,
2318 width: w,
2319 height: h,
2320 });
2321 }
2322 }
2323 if let Some((w, h)) = meta.end_left {
2324 if let Some((x, y)) =
2325 calc_terminal_label_position(meta.end_marker, TerminalPos::EndLeft, &points)
2326 {
2327 edge.end_label_left = Some(LayoutLabel {
2328 x,
2329 y,
2330 width: w,
2331 height: h,
2332 });
2333 }
2334 }
2335 if let Some((w, h)) = meta.end_right {
2336 if let Some((x, y)) =
2337 calc_terminal_label_position(meta.end_marker, TerminalPos::EndRight, &points)
2338 {
2339 edge.end_label_right = Some(LayoutLabel {
2340 x,
2341 y,
2342 width: w,
2343 height: h,
2344 });
2345 }
2346 }
2347 }
2348
2349 let title_margin_top = config_f64(
2350 effective_config,
2351 &["flowchart", "subGraphTitleMargin", "top"],
2352 )
2353 .unwrap_or(0.0);
2354 let title_margin_bottom = config_f64(
2355 effective_config,
2356 &["flowchart", "subGraphTitleMargin", "bottom"],
2357 )
2358 .unwrap_or(0.0);
2359
2360 let mut clusters: Vec<LayoutCluster> = Vec::new();
2361 for &id in &namespace_ids {
2365 let Some(ns_node) = fragments.nodes.get(id) else {
2366 continue;
2367 };
2368 let cx = ns_node.x;
2369 let cy = ns_node.y;
2370 let base_w = ns_node.width.max(1.0);
2371 let base_h = ns_node.height.max(1.0);
2372
2373 let title = class_namespace_label(model, id).to_string();
2374 let (mut tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
2375 if let Some(width) = class_namespace_known_rendered_width_override_px(&title, &text_style) {
2376 tw = width;
2377 }
2378 let min_title_w = (tw + namespace_padding).max(1.0);
2379 let width = if base_w <= min_title_w {
2380 min_title_w
2381 } else {
2382 base_w
2383 };
2384 let diff = if base_w <= min_title_w {
2385 (width - base_w) / 2.0 - namespace_padding
2386 } else {
2387 -namespace_padding
2388 };
2389 let offset_y = th - namespace_padding / 2.0;
2390 let title_label = LayoutLabel {
2391 x: cx,
2392 y: (cy - base_h / 2.0) + title_margin_top + th / 2.0,
2393 width: tw,
2394 height: th,
2395 };
2396
2397 clusters.push(LayoutCluster {
2398 id: id.to_string(),
2399 x: cx,
2400 y: cy,
2401 width,
2402 height: base_h,
2403 diff,
2404 offset_y,
2405 title: title.clone(),
2406 title_label,
2407 requested_dir: None,
2408 effective_dir: normalize_dir(&model.direction),
2409 padding: namespace_padding,
2410 title_margin_top,
2411 title_margin_bottom,
2412 });
2413 }
2414
2415 let mut nodes: Vec<LayoutNode> = fragments.nodes.into_values().collect();
2418 nodes.sort_by(|a, b| a.id.cmp(&b.id));
2419
2420 let mut edges: Vec<LayoutEdge> = fragments.edges.into_iter().map(|(e, _)| e).collect();
2421 edges.sort_by(|a, b| a.id.cmp(&b.id));
2422
2423 let namespace_order: std::collections::HashMap<&str, usize> = namespace_ids
2424 .iter()
2425 .copied()
2426 .enumerate()
2427 .map(|(idx, id)| (id, idx))
2428 .collect();
2429 clusters.sort_by(|a, b| {
2430 namespace_order
2431 .get(a.id.as_str())
2432 .copied()
2433 .unwrap_or(usize::MAX)
2434 .cmp(
2435 &namespace_order
2436 .get(b.id.as_str())
2437 .copied()
2438 .unwrap_or(usize::MAX),
2439 )
2440 .then_with(|| a.id.cmp(&b.id))
2441 });
2442
2443 let mut bounds = compute_bounds(&nodes, &edges, &clusters);
2444 if should_mirror_note_heavy_tb_layout(model, &nodes) {
2445 if let Some(axis_x) = bounds.as_ref().map(|b| (b.min_x + b.max_x) / 2.0) {
2446 mirror_class_layout_x(&mut nodes, &mut edges, &mut clusters, axis_x);
2450 bounds = compute_bounds(&nodes, &edges, &clusters);
2451 }
2452 }
2453
2454 Ok(ClassDiagramV2Layout {
2455 nodes,
2456 edges,
2457 clusters,
2458 bounds,
2459 class_row_metrics_by_id,
2460 })
2461}
2462
2463fn validate_class_namespace_parent_cycles(model: &ClassDiagramModel) -> Result<()> {
2464 for id in model.namespaces.keys() {
2465 let mut current = Some(id.as_str());
2466 let mut seen: HashSet<&str> = HashSet::new();
2467 while let Some(ns_id) = current {
2468 if !seen.insert(ns_id) {
2469 return Err(Error::InvalidModel {
2470 message: format!("class namespace parent cycle involving {ns_id}"),
2471 });
2472 }
2473 current = model
2474 .namespaces
2475 .get(ns_id)
2476 .and_then(|ns| ns.parent.as_deref());
2477 }
2478 }
2479 Ok(())
2480}
2481
2482fn mirror_layout_x_coord(x: f64, axis_x: f64) -> f64 {
2483 axis_x * 2.0 - x
2484}
2485
2486fn mirror_layout_label_x(label: &mut LayoutLabel, axis_x: f64) {
2487 label.x = mirror_layout_x_coord(label.x, axis_x);
2488}
2489
2490fn mirror_class_layout_x(
2491 nodes: &mut [LayoutNode],
2492 edges: &mut [LayoutEdge],
2493 clusters: &mut [LayoutCluster],
2494 axis_x: f64,
2495) {
2496 for node in nodes {
2497 node.x = mirror_layout_x_coord(node.x, axis_x);
2498 }
2499
2500 for edge in edges {
2501 for point in &mut edge.points {
2502 point.x = mirror_layout_x_coord(point.x, axis_x);
2503 }
2504 if let Some(label) = edge.label.as_mut() {
2505 mirror_layout_label_x(label, axis_x);
2506 }
2507 if let Some(label) = edge.start_label_left.as_mut() {
2508 mirror_layout_label_x(label, axis_x);
2509 }
2510 if let Some(label) = edge.start_label_right.as_mut() {
2511 mirror_layout_label_x(label, axis_x);
2512 }
2513 if let Some(label) = edge.end_label_left.as_mut() {
2514 mirror_layout_label_x(label, axis_x);
2515 }
2516 if let Some(label) = edge.end_label_right.as_mut() {
2517 mirror_layout_label_x(label, axis_x);
2518 }
2519 }
2520
2521 for cluster in clusters {
2522 cluster.x = mirror_layout_x_coord(cluster.x, axis_x);
2523 mirror_layout_label_x(&mut cluster.title_label, axis_x);
2524 }
2525}
2526
2527fn should_mirror_note_heavy_tb_layout(model: &ClassDiagramModel, nodes: &[LayoutNode]) -> bool {
2528 if normalize_dir(&model.direction) != "TB" {
2529 return false;
2530 }
2531 if !model.namespaces.is_empty() {
2532 return false;
2533 }
2534
2535 let attached_notes: Vec<(&str, &str)> = model
2536 .notes
2537 .iter()
2538 .filter_map(|note| {
2539 note.class_id
2540 .as_deref()
2541 .map(|class_id| (note.id.as_str(), class_id))
2542 })
2543 .collect();
2544 if attached_notes.len() < 2 {
2545 return false;
2546 }
2547
2548 let node_x_by_id: HashMap<&str, f64> = nodes
2549 .iter()
2550 .map(|node| (node.id.as_str(), node.x))
2551 .collect();
2552
2553 let mut positive_note_offsets = 0usize;
2554 let mut negative_note_offsets = 0usize;
2555 for (note_id, class_id) in attached_notes {
2556 let (Some(note_x), Some(class_x)) = (
2557 node_x_by_id.get(note_id).copied(),
2558 node_x_by_id.get(class_id).copied(),
2559 ) else {
2560 continue;
2561 };
2562 let delta_x = note_x - class_x;
2563 if delta_x > 0.5 {
2564 positive_note_offsets += 1;
2565 } else if delta_x < -0.5 {
2566 negative_note_offsets += 1;
2567 }
2568 }
2569 if positive_note_offsets == 0 || negative_note_offsets != 0 {
2570 return false;
2571 }
2572
2573 let Some((from_x, to_x)) = model.relations.iter().find_map(|relation| {
2574 if model.classes.get(relation.id1.as_str()).is_none()
2575 || model.classes.get(relation.id2.as_str()).is_none()
2576 {
2577 return None;
2578 }
2579 let from_x = node_x_by_id.get(relation.id1.as_str()).copied()?;
2580 let to_x = node_x_by_id.get(relation.id2.as_str()).copied()?;
2581 Some((from_x, to_x))
2582 }) else {
2583 return false;
2584 };
2585
2586 from_x + 0.5 < to_x
2587}
2588
2589fn compute_bounds(
2590 nodes: &[LayoutNode],
2591 edges: &[LayoutEdge],
2592 clusters: &[LayoutCluster],
2593) -> Option<Bounds> {
2594 let mut points: Vec<(f64, f64)> = Vec::new();
2595
2596 for c in clusters {
2597 let r = Rect::from_center(c.x, c.y, c.width, c.height);
2598 points.push((r.min_x(), r.min_y()));
2599 points.push((r.max_x(), r.max_y()));
2600 let lr = Rect::from_center(
2601 c.title_label.x,
2602 c.title_label.y,
2603 c.title_label.width,
2604 c.title_label.height,
2605 );
2606 points.push((lr.min_x(), lr.min_y()));
2607 points.push((lr.max_x(), lr.max_y()));
2608 }
2609
2610 for n in nodes {
2611 let r = Rect::from_center(n.x, n.y, n.width, n.height);
2612 points.push((r.min_x(), r.min_y()));
2613 points.push((r.max_x(), r.max_y()));
2614 }
2615
2616 for e in edges {
2617 for p in &e.points {
2618 points.push((p.x, p.y));
2619 }
2620 for l in [
2621 e.label.as_ref(),
2622 e.start_label_left.as_ref(),
2623 e.start_label_right.as_ref(),
2624 e.end_label_left.as_ref(),
2625 e.end_label_right.as_ref(),
2626 ]
2627 .into_iter()
2628 .flatten()
2629 {
2630 let r = Rect::from_center(l.x, l.y, l.width, l.height);
2631 points.push((r.min_x(), r.min_y()));
2632 points.push((r.max_x(), r.max_y()));
2633 }
2634 }
2635
2636 Bounds::from_points(points)
2637}