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