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