1use crate::entities::decode_entities_minimal;
2use crate::model::{
3 Bounds, ClassDiagramV2Layout, ClassNodeRowMetrics, LayoutCluster, LayoutEdge, LayoutLabel,
4 LayoutNode, LayoutPoint,
5};
6use crate::text::{TextMeasurer, TextStyle, WrapMode};
7use crate::{Error, Result};
8use dugong::graphlib::{Graph, GraphOptions};
9use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
10use indexmap::IndexMap;
11use rustc_hash::FxHashMap;
12use serde_json::Value;
13use std::collections::{BTreeMap, HashMap, HashSet};
14use std::sync::Arc;
15
16type ClassDiagramModel = merman_core::models::class_diagram::ClassDiagram;
17type ClassNode = merman_core::models::class_diagram::ClassNode;
18
19fn json_f64(v: &Value) -> Option<f64> {
20 v.as_f64()
21 .or_else(|| v.as_i64().map(|n| n as f64))
22 .or_else(|| v.as_u64().map(|n| n as f64))
23}
24
25fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
26 let mut cur = cfg;
27 for key in path {
28 cur = cur.get(*key)?;
29 }
30 json_f64(cur)
31}
32
33fn config_bool(cfg: &Value, path: &[&str]) -> Option<bool> {
34 let mut cur = cfg;
35 for key in path {
36 cur = cur.get(*key)?;
37 }
38 cur.as_bool()
39}
40
41fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
42 let mut cur = cfg;
43 for key in path {
44 cur = cur.get(*key)?;
45 }
46 cur.as_str().map(|s| s.to_string())
47}
48
49fn parse_css_px_to_f64(s: &str) -> Option<f64> {
50 let s = s.trim();
51 let raw = s.strip_suffix("px").unwrap_or(s).trim();
52 raw.parse::<f64>().ok().filter(|v| v.is_finite())
53}
54
55fn config_f64_css_px(cfg: &Value, path: &[&str]) -> Option<f64> {
56 config_f64(cfg, path).or_else(|| {
57 let s = config_string(cfg, path)?;
58 parse_css_px_to_f64(&s)
59 })
60}
61
62fn normalize_dir(direction: &str) -> String {
63 match direction.trim().to_uppercase().as_str() {
64 "TB" | "TD" => "TB".to_string(),
65 "BT" => "BT".to_string(),
66 "LR" => "LR".to_string(),
67 "RL" => "RL".to_string(),
68 other => other.to_string(),
69 }
70}
71
72fn rank_dir_from(direction: &str) -> RankDir {
73 match normalize_dir(direction).as_str() {
74 "TB" => RankDir::TB,
75 "BT" => RankDir::BT,
76 "LR" => RankDir::LR,
77 "RL" => RankDir::RL,
78 _ => RankDir::TB,
79 }
80}
81
82fn class_dom_decl_order_index(dom_id: &str) -> usize {
83 dom_id
84 .rsplit_once('-')
85 .and_then(|(_, suffix)| suffix.parse::<usize>().ok())
86 .unwrap_or(usize::MAX)
87}
88
89pub(crate) fn class_namespace_ids_in_decl_order(model: &ClassDiagramModel) -> Vec<&str> {
90 let mut namespaces: Vec<_> = model.namespaces.values().collect();
91 namespaces.sort_by(|lhs, rhs| {
92 class_dom_decl_order_index(&lhs.dom_id)
93 .cmp(&class_dom_decl_order_index(&rhs.dom_id))
94 .then_with(|| lhs.id.cmp(&rhs.id))
95 });
96 namespaces.into_iter().map(|ns| ns.id.as_str()).collect()
97}
98
99type Rect = merman_core::geom::Box2;
100
101struct PreparedGraph {
102 graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
103 extracted: BTreeMap<String, PreparedGraph>,
104 injected_cluster_root_id: Option<String>,
105}
106
107fn extract_descendants(
108 graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
109 id: &str,
110 out: &mut Vec<String>,
111) {
112 for child in graph.children(id) {
113 out.push(child.to_string());
114 extract_descendants(graph, child, out);
115 }
116}
117
118fn is_descendant(descendants: &HashMap<String, HashSet<String>>, id: &str, ancestor: &str) -> bool {
119 descendants
120 .get(ancestor)
121 .is_some_and(|set| set.contains(id))
122}
123
124fn prepare_graph(
125 mut graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
126 depth: usize,
127 prefer_dagreish_disconnected: bool,
128) -> Result<PreparedGraph> {
129 if depth > 10 {
130 return Ok(PreparedGraph {
131 graph,
132 extracted: BTreeMap::new(),
133 injected_cluster_root_id: None,
134 });
135 }
136
137 let cluster_ids: Vec<String> = graph
148 .node_ids()
149 .into_iter()
150 .filter(|id| !graph.children(id).is_empty())
151 .collect();
152
153 let mut descendants: HashMap<String, HashSet<String>> = HashMap::new();
154 for id in &cluster_ids {
155 let mut vec: Vec<String> = Vec::new();
156 extract_descendants(&graph, id, &mut vec);
157 descendants.insert(id.clone(), vec.into_iter().collect());
158 }
159
160 let mut external: HashMap<String, bool> =
161 cluster_ids.iter().map(|id| (id.clone(), false)).collect();
162 for id in &cluster_ids {
163 for e in graph.edge_keys() {
164 if e.v == *id || e.w == *id {
168 continue;
169 }
170 let d1 = is_descendant(&descendants, &e.v, id);
171 let d2 = is_descendant(&descendants, &e.w, id);
172 if d1 ^ d2 {
173 external.insert(id.clone(), true);
174 break;
175 }
176 }
177 }
178
179 let mut extracted: BTreeMap<String, PreparedGraph> = BTreeMap::new();
180 let candidate_clusters: Vec<String> = graph
181 .node_ids()
182 .into_iter()
183 .filter(|id| !graph.children(id).is_empty() && !external.get(id).copied().unwrap_or(false))
184 .collect();
185
186 for cluster_id in candidate_clusters {
187 if graph.children(&cluster_id).is_empty() {
188 continue;
189 }
190 let parent_dir = graph.graph().rankdir;
191 let dir = if parent_dir == RankDir::TB {
192 RankDir::LR
193 } else {
194 RankDir::TB
195 };
196
197 let nodesep = graph.graph().nodesep;
198 let ranksep = graph.graph().ranksep;
199
200 let mut subgraph = extract_cluster_graph(&cluster_id, &mut graph)?;
201 subgraph.graph_mut().rankdir = dir;
202 subgraph.graph_mut().nodesep = nodesep;
203 subgraph.graph_mut().ranksep = ranksep + 25.0;
204 subgraph.graph_mut().marginx = 8.0;
205 subgraph.graph_mut().marginy = 8.0;
206
207 let mut prepared = prepare_graph(subgraph, depth + 1, prefer_dagreish_disconnected)?;
208 prepared.injected_cluster_root_id = Some(cluster_id.clone());
209 extracted.insert(cluster_id, prepared);
210 }
211
212 Ok(PreparedGraph {
213 graph,
214 extracted,
215 injected_cluster_root_id: None,
216 })
217}
218
219fn extract_cluster_graph(
220 cluster_id: &str,
221 graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
222) -> Result<Graph<NodeLabel, EdgeLabel, GraphLabel>> {
223 if graph.children(cluster_id).is_empty() {
224 return Err(Error::InvalidModel {
225 message: format!("cluster has no children: {cluster_id}"),
226 });
227 }
228
229 let mut descendants: Vec<String> = Vec::new();
230 extract_descendants(graph, cluster_id, &mut descendants);
231 descendants.sort();
232 descendants.dedup();
233
234 let moved_set: HashSet<String> = descendants.iter().cloned().collect();
235
236 let mut sub = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
237 directed: true,
238 multigraph: true,
239 compound: true,
240 });
241
242 sub.set_graph(graph.graph().clone());
244
245 for id in &descendants {
246 let Some(label) = graph.node(id).cloned() else {
247 continue;
248 };
249 sub.set_node(id.clone(), label);
250 }
251
252 for key in graph.edge_keys() {
253 if moved_set.contains(&key.v) && moved_set.contains(&key.w) {
254 if let Some(label) = graph.edge_by_key(&key).cloned() {
255 sub.set_edge_named(key.v.clone(), key.w.clone(), key.name.clone(), Some(label));
256 }
257 }
258 }
259
260 for id in &descendants {
261 let Some(parent) = graph.parent(id) else {
262 continue;
263 };
264 if moved_set.contains(parent) {
265 sub.set_parent(id.clone(), parent.to_string());
266 }
267 }
268
269 for id in &descendants {
270 let _ = graph.remove_node(id);
271 }
272
273 Ok(sub)
274}
275
276#[derive(Debug, Clone)]
277struct EdgeTerminalMetrics {
278 start_left: Option<(f64, f64)>,
279 start_right: Option<(f64, f64)>,
280 end_left: Option<(f64, f64)>,
281 end_right: Option<(f64, f64)>,
282 start_marker: f64,
283 end_marker: f64,
284}
285
286fn edge_terminal_metrics_from_extras(e: &EdgeLabel) -> EdgeTerminalMetrics {
287 let get_pair = |key: &str| -> Option<(f64, f64)> {
288 let obj = e.extras.get(key)?;
289 let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0);
290 let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0);
291 if w > 0.0 && h > 0.0 {
292 Some((w, h))
293 } else {
294 None
295 }
296 };
297 let start_marker = e
298 .extras
299 .get("startMarker")
300 .and_then(|v| v.as_f64())
301 .unwrap_or(0.0);
302 let end_marker = e
303 .extras
304 .get("endMarker")
305 .and_then(|v| v.as_f64())
306 .unwrap_or(0.0);
307 EdgeTerminalMetrics {
308 start_left: get_pair("startLeft"),
309 start_right: get_pair("startRight"),
310 end_left: get_pair("endLeft"),
311 end_right: get_pair("endRight"),
312 start_marker,
313 end_marker,
314 }
315}
316
317#[derive(Debug, Clone)]
318struct LayoutFragments {
319 nodes: IndexMap<String, LayoutNode>,
320 edges: Vec<(LayoutEdge, Option<EdgeTerminalMetrics>)>,
321}
322
323fn round_number(num: f64, precision: i32) -> f64 {
324 if !num.is_finite() {
325 return 0.0;
326 }
327 let factor = 10_f64.powi(precision);
328 (num * factor).round() / factor
329}
330
331fn distance(a: &LayoutPoint, b: Option<&LayoutPoint>) -> f64 {
332 let Some(b) = b else {
333 return 0.0;
334 };
335 let dx = a.x - b.x;
336 let dy = a.y - b.y;
337 (dx * dx + dy * dy).sqrt()
338}
339
340fn calculate_point(points: &[LayoutPoint], distance_to_traverse: f64) -> Option<LayoutPoint> {
341 if points.is_empty() {
342 return None;
343 }
344 let mut prev: Option<&LayoutPoint> = None;
345 let mut remaining = distance_to_traverse.max(0.0);
346 for p in points {
347 if let Some(prev_p) = prev {
348 let vector_distance = distance(p, Some(prev_p));
349 if vector_distance == 0.0 {
350 return Some(prev_p.clone());
351 }
352 if vector_distance < remaining {
353 remaining -= vector_distance;
354 } else {
355 let ratio = remaining / vector_distance;
356 if ratio <= 0.0 {
357 return Some(prev_p.clone());
358 }
359 if ratio >= 1.0 {
360 return Some(p.clone());
361 }
362 return Some(LayoutPoint {
363 x: round_number((1.0 - ratio) * prev_p.x + ratio * p.x, 5),
364 y: round_number((1.0 - ratio) * prev_p.y + ratio * p.y, 5),
365 });
366 }
367 }
368 prev = Some(p);
369 }
370 None
371}
372
373#[derive(Debug, Clone, Copy)]
374enum TerminalPos {
375 StartLeft,
376 StartRight,
377 EndLeft,
378 EndRight,
379}
380
381fn calc_terminal_label_position(
382 terminal_marker_size: f64,
383 position: TerminalPos,
384 points: &[LayoutPoint],
385) -> Option<(f64, f64)> {
386 if points.len() < 2 {
387 return None;
388 }
389
390 let mut pts = points.to_vec();
391 match position {
392 TerminalPos::StartLeft | TerminalPos::StartRight => {}
393 TerminalPos::EndLeft | TerminalPos::EndRight => pts.reverse(),
394 }
395
396 let distance_to_cardinality_point = 25.0 + terminal_marker_size;
397 let center = calculate_point(&pts, distance_to_cardinality_point)?;
398 let d = 10.0 + terminal_marker_size * 0.5;
399 let angle = (pts[0].y - center.y).atan2(pts[0].x - center.x);
400
401 let (x, y) = match position {
402 TerminalPos::StartLeft => {
403 let a = angle + std::f64::consts::PI;
404 (
405 a.sin() * d + (pts[0].x + center.x) / 2.0,
406 -a.cos() * d + (pts[0].y + center.y) / 2.0,
407 )
408 }
409 TerminalPos::StartRight => (
410 angle.sin() * d + (pts[0].x + center.x) / 2.0,
411 -angle.cos() * d + (pts[0].y + center.y) / 2.0,
412 ),
413 TerminalPos::EndLeft => (
414 angle.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
415 -angle.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
416 ),
417 TerminalPos::EndRight => {
418 let a = angle - std::f64::consts::PI;
419 (
420 a.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
421 -a.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
422 )
423 }
424 };
425 Some((x, y))
426}
427
428fn intersect_segment_with_rect(
429 p0: &LayoutPoint,
430 p1: &LayoutPoint,
431 rect: Rect,
432) -> Option<LayoutPoint> {
433 let dx = p1.x - p0.x;
434 let dy = p1.y - p0.y;
435 if dx == 0.0 && dy == 0.0 {
436 return None;
437 }
438
439 let mut candidates: Vec<(f64, LayoutPoint)> = Vec::new();
440 let eps = 1e-9;
441 let min_x = rect.min_x();
442 let max_x = rect.max_x();
443 let min_y = rect.min_y();
444 let max_y = rect.max_y();
445
446 if dx.abs() > eps {
447 for x_edge in [min_x, max_x] {
448 let t = (x_edge - p0.x) / dx;
449 if t < -eps || t > 1.0 + eps {
450 continue;
451 }
452 let y = p0.y + t * dy;
453 if y + eps >= min_y && y <= max_y + eps {
454 candidates.push((t, LayoutPoint { x: x_edge, y }));
455 }
456 }
457 }
458
459 if dy.abs() > eps {
460 for y_edge in [min_y, max_y] {
461 let t = (y_edge - p0.y) / dy;
462 if t < -eps || t > 1.0 + eps {
463 continue;
464 }
465 let x = p0.x + t * dx;
466 if x + eps >= min_x && x <= max_x + eps {
467 candidates.push((t, LayoutPoint { x, y: y_edge }));
468 }
469 }
470 }
471
472 candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
473 candidates
474 .into_iter()
475 .find(|(t, _)| *t >= 0.0)
476 .map(|(_, p)| p)
477}
478
479fn terminal_path_for_edge(
480 points: &[LayoutPoint],
481 from_rect: Rect,
482 to_rect: Rect,
483) -> Vec<LayoutPoint> {
484 if points.len() < 2 {
485 return points.to_vec();
486 }
487 let mut out = points.to_vec();
488
489 if let Some(p) = intersect_segment_with_rect(&out[0], &out[1], from_rect) {
490 out[0] = p;
491 }
492 let last = out.len() - 1;
493 if let Some(p) = intersect_segment_with_rect(&out[last], &out[last - 1], to_rect) {
494 out[last] = p;
495 }
496
497 out
498}
499
500fn layout_prepared(
501 prepared: &mut PreparedGraph,
502 node_label_metrics_by_id: &HashMap<String, (f64, f64)>,
503) -> Result<(LayoutFragments, Rect)> {
504 let mut fragments = LayoutFragments {
505 nodes: IndexMap::new(),
506 edges: Vec::new(),
507 };
508
509 if let Some(root_id) = prepared.injected_cluster_root_id.clone() {
510 if prepared.graph.node(&root_id).is_none() {
511 prepared
512 .graph
513 .set_node(root_id.clone(), NodeLabel::default());
514 }
515 let top_level_ids: Vec<String> = prepared
516 .graph
517 .node_ids()
518 .into_iter()
519 .filter(|id| id != &root_id && prepared.graph.parent(id).is_none())
520 .collect();
521 for id in top_level_ids {
522 prepared.graph.set_parent(id, root_id.clone());
523 }
524 }
525
526 let extracted_ids: Vec<String> = prepared.extracted.keys().cloned().collect();
527 let mut extracted_fragments: BTreeMap<String, (LayoutFragments, Rect)> = BTreeMap::new();
528 for id in extracted_ids {
529 let sub = prepared.extracted.get_mut(&id).expect("exists");
530 let (sub_frag, sub_bounds) = layout_prepared(sub, node_label_metrics_by_id)?;
531
532 extracted_fragments.insert(id, (sub_frag, sub_bounds));
538 }
539
540 for (id, (_sub_frag, bounds)) in &extracted_fragments {
541 let Some(n) = prepared.graph.node_mut(id) else {
542 return Err(Error::InvalidModel {
543 message: format!("missing cluster placeholder node: {id}"),
544 });
545 };
546 n.width = bounds.width().max(1.0);
547 n.height = bounds.height().max(1.0);
548 }
549
550 dugong::layout_dagreish(&mut prepared.graph);
554
555 let mut dummy_nodes: HashSet<String> = HashSet::new();
559 for id in prepared.graph.node_ids() {
560 let Some(n) = prepared.graph.node(&id) else {
561 continue;
562 };
563 if n.dummy.is_some() {
564 dummy_nodes.insert(id);
565 continue;
566 }
567 let is_cluster =
568 !prepared.graph.children(&id).is_empty() || prepared.extracted.contains_key(&id);
569 let (label_width, label_height) = node_label_metrics_by_id
570 .get(id.as_str())
571 .copied()
572 .map(|(w, h)| (Some(w), Some(h)))
573 .unwrap_or((None, None));
574 fragments.nodes.insert(
575 id.clone(),
576 LayoutNode {
577 id: id.clone(),
578 x: n.x.unwrap_or(0.0),
579 y: n.y.unwrap_or(0.0),
580 width: n.width,
581 height: n.height,
582 is_cluster,
583 label_width,
584 label_height,
585 },
586 );
587 }
588
589 for key in prepared.graph.edge_keys() {
590 let Some(e) = prepared.graph.edge_by_key(&key) else {
591 continue;
592 };
593 if e.nesting_edge {
594 continue;
595 }
596 if dummy_nodes.contains(&key.v) || dummy_nodes.contains(&key.w) {
597 continue;
598 }
599 if !fragments.nodes.contains_key(&key.v) || !fragments.nodes.contains_key(&key.w) {
600 continue;
601 }
602 let id = key
603 .name
604 .clone()
605 .unwrap_or_else(|| format!("edge:{}:{}", key.v, key.w));
606
607 let label = if e.width > 0.0 && e.height > 0.0 {
608 Some(LayoutLabel {
609 x: e.x.unwrap_or(0.0),
610 y: e.y.unwrap_or(0.0),
611 width: e.width,
612 height: e.height,
613 })
614 } else {
615 None
616 };
617
618 let points = e
619 .points
620 .iter()
621 .map(|p| LayoutPoint { x: p.x, y: p.y })
622 .collect::<Vec<_>>();
623
624 let edge = LayoutEdge {
625 id,
626 from: key.v.clone(),
627 to: key.w.clone(),
628 from_cluster: None,
629 to_cluster: None,
630 points,
631 label,
632 start_label_left: None,
633 start_label_right: None,
634 end_label_left: None,
635 end_label_right: None,
636 start_marker: None,
637 end_marker: None,
638 stroke_dasharray: None,
639 };
640
641 let terminals = edge_terminal_metrics_from_extras(e);
642 let has_terminals = terminals.start_left.is_some()
643 || terminals.start_right.is_some()
644 || terminals.end_left.is_some()
645 || terminals.end_right.is_some();
646 let terminal_meta = if has_terminals { Some(terminals) } else { None };
647
648 fragments.edges.push((edge, terminal_meta));
649 }
650
651 for (cluster_id, (mut sub_frag, sub_bounds)) in extracted_fragments {
652 let Some(cluster_node) = fragments.nodes.get(&cluster_id).cloned() else {
653 return Err(Error::InvalidModel {
654 message: format!("missing cluster placeholder layout: {cluster_id}"),
655 });
656 };
657 let (sub_cx, sub_cy) = sub_bounds.center();
658 let dx = cluster_node.x - sub_cx;
659 let dy = cluster_node.y - sub_cy;
660
661 for n in sub_frag.nodes.values_mut() {
662 n.x += dx;
663 n.y += dy;
664 }
665 for (e, _t) in &mut sub_frag.edges {
666 for p in &mut e.points {
667 p.x += dx;
668 p.y += dy;
669 }
670 if let Some(l) = e.label.as_mut() {
671 l.x += dx;
672 l.y += dy;
673 }
674 }
675
676 let _ = sub_frag.nodes.swap_remove(&cluster_id);
680
681 fragments.nodes.extend(sub_frag.nodes);
682 fragments.edges.extend(sub_frag.edges);
683 }
684
685 let mut points: Vec<(f64, f64)> = Vec::new();
686 for n in fragments.nodes.values() {
687 let r = Rect::from_center(n.x, n.y, n.width, n.height);
688 points.push((r.min_x(), r.min_y()));
689 points.push((r.max_x(), r.max_y()));
690 }
691 for (e, _t) in &fragments.edges {
692 for p in &e.points {
693 points.push((p.x, p.y));
694 }
695 if let Some(l) = &e.label {
696 let r = Rect::from_center(l.x, l.y, l.width, l.height);
697 points.push((r.min_x(), r.min_y()));
698 points.push((r.max_x(), r.max_y()));
699 }
700 }
701 let bounds = Bounds::from_points(points)
702 .map(|b| Rect::from_min_max(b.min_x, b.min_y, b.max_x, b.max_y))
703 .unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
704
705 Ok((fragments, bounds))
706}
707
708fn class_text_style(effective_config: &Value, wrap_mode: WrapMode) -> TextStyle {
709 let font_family = config_string(effective_config, &["fontFamily"])
712 .or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
713 .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
714 let font_size = match wrap_mode {
715 WrapMode::HtmlLike => {
716 16.0
725 }
726 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => {
727 let theme_px = config_string(effective_config, &["themeVariables", "fontSize"])
734 .and_then(|raw| {
735 let t = raw.trim().trim_end_matches(';').trim();
736 let t = t.trim_end_matches("!important").trim();
737 if !t.ends_with("px") {
738 return None;
739 }
740 t.trim_end_matches("px").trim().parse::<f64>().ok()
741 })
742 .unwrap_or(16.0);
743 theme_px
744 }
745 };
746 TextStyle {
747 font_family,
748 font_size,
749 font_weight: None,
750 }
751}
752
753pub(crate) fn class_html_calculate_text_style(effective_config: &Value) -> TextStyle {
754 TextStyle {
755 font_family: config_string(effective_config, &["fontFamily"])
756 .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif;".to_string())),
757 font_size: config_f64_css_px(effective_config, &["fontSize"])
758 .unwrap_or(16.0)
759 .max(1.0),
760 font_weight: None,
761 }
762}
763
764fn class_box_dimensions(
765 node: &ClassNode,
766 measurer: &dyn TextMeasurer,
767 text_style: &TextStyle,
768 html_calc_text_style: &TextStyle,
769 wrap_probe_font_size: f64,
770 wrap_mode: WrapMode,
771 padding: f64,
772 hide_empty_members_box: bool,
773 capture_row_metrics: bool,
774) -> (f64, f64, Option<ClassNodeRowMetrics>) {
775 let use_html_labels = matches!(wrap_mode, WrapMode::HtmlLike);
781 let padding = padding.max(0.0);
782 let gap = padding;
783 let text_padding = if use_html_labels { 0.0 } else { 3.0 };
784
785 fn mermaid_class_svg_create_text_width_px(
786 measurer: &dyn TextMeasurer,
787 text: &str,
788 style: &TextStyle,
789 wrap_probe_font_size: f64,
790 ) -> Option<f64> {
791 let wrap_probe_font_size = wrap_probe_font_size.max(1.0);
792 let wrap_probe_style = TextStyle {
796 font_family: style
797 .font_family
798 .clone()
799 .or_else(|| Some("Arial".to_string())),
800 font_size: wrap_probe_font_size,
801 font_weight: None,
802 };
803 let sans_probe_style = TextStyle {
804 font_family: Some("sans-serif".to_string()),
805 font_size: wrap_probe_font_size,
806 font_weight: None,
807 };
808 #[derive(Clone, Copy)]
816 struct Dim {
817 width: f64,
818 height: f64,
819 line_height: f64,
820 }
821 fn dim_for(measurer: &dyn TextMeasurer, text: &str, style: &TextStyle) -> Dim {
822 let width = measurer
823 .measure_svg_simple_text_bbox_width_px(text, style)
824 .max(0.0)
825 .round();
826 let height = measurer
827 .measure_wrapped(text, style, None, WrapMode::SvgLike)
828 .height
829 .max(0.0)
830 .round();
831 Dim {
832 width,
833 height,
834 line_height: height,
835 }
836 }
837 let dims = [
838 dim_for(measurer, text, &sans_probe_style),
839 dim_for(measurer, text, &wrap_probe_style),
840 ];
841 let pick_sans = dims[1].height.is_nan()
842 || dims[1].width.is_nan()
843 || dims[1].line_height.is_nan()
844 || (dims[0].height > dims[1].height
845 && dims[0].width > dims[1].width
846 && dims[0].line_height > dims[1].line_height);
847 let w = dims[if pick_sans { 0 } else { 1 }].width + 50.0;
848 if w.is_finite() && w > 0.0 {
849 Some(w)
850 } else {
851 None
852 }
853 }
854
855 fn wrap_class_svg_text_like_mermaid(
856 text: &str,
857 measurer: &dyn TextMeasurer,
858 style: &TextStyle,
859 wrap_probe_font_size: f64,
860 bold: bool,
861 ) -> String {
862 let Some(wrap_width_px) =
863 mermaid_class_svg_create_text_width_px(measurer, text, style, wrap_probe_font_size)
864 else {
865 return text.to_string();
866 };
867 let computed_len_fudge = if bold {
872 1.0
873 } else if style.font_size >= 20.0 {
874 1.035
875 } else {
876 1.02
877 };
878
879 let mut lines: Vec<String> = Vec::new();
880 for line in crate::text::DeterministicTextMeasurer::normalized_text_lines(text) {
881 let mut tokens = std::collections::VecDeque::from(
882 crate::text::DeterministicTextMeasurer::split_line_to_words(&line),
883 );
884 let mut cur = String::new();
885
886 while let Some(tok) = tokens.pop_front() {
887 if cur.is_empty() && tok == " " {
888 continue;
889 }
890
891 let candidate = format!("{cur}{tok}");
892 let candidate_w = if bold {
893 let bold_style = TextStyle {
894 font_family: style.font_family.clone(),
895 font_size: style.font_size,
896 font_weight: Some("bolder".to_string()),
897 };
898 measurer.measure_svg_text_computed_length_px(candidate.trim_end(), &bold_style)
899 } else {
900 measurer.measure_svg_text_computed_length_px(candidate.trim_end(), style)
901 };
902 let candidate_w = candidate_w * computed_len_fudge;
903 if candidate_w <= wrap_width_px {
904 cur = candidate;
905 continue;
906 }
907
908 if !cur.trim().is_empty() {
909 lines.push(cur.trim_end().to_string());
910 cur.clear();
911 tokens.push_front(tok);
912 continue;
913 }
914
915 if tok == " " {
916 continue;
917 }
918
919 let chars = tok.chars().collect::<Vec<_>>();
921 let mut cut = 1usize;
922 while cut < chars.len() {
923 let head: String = chars[..cut].iter().collect();
924 let head_w = if bold {
925 let bold_style = TextStyle {
926 font_family: style.font_family.clone(),
927 font_size: style.font_size,
928 font_weight: Some("bolder".to_string()),
929 };
930 measurer.measure_svg_text_computed_length_px(head.as_str(), &bold_style)
931 } else {
932 measurer.measure_svg_text_computed_length_px(head.as_str(), style)
933 };
934 let head_w = head_w * computed_len_fudge;
935 if head_w > wrap_width_px {
936 break;
937 }
938 cut += 1;
939 }
940 cut = cut.saturating_sub(1).max(1);
941 let head: String = chars[..cut].iter().collect();
942 let tail: String = chars[cut..].iter().collect();
943 lines.push(head);
944 if !tail.is_empty() {
945 tokens.push_front(tail);
946 }
947 }
948
949 if !cur.trim().is_empty() {
950 lines.push(cur.trim_end().to_string());
951 }
952 }
953
954 if lines.len() <= 1 {
955 text.to_string()
956 } else {
957 lines.join("\n")
958 }
959 }
960
961 fn measure_label(
962 measurer: &dyn TextMeasurer,
963 text: &str,
964 css_style: &str,
965 style: &TextStyle,
966 html_calc_text_style: &TextStyle,
967 wrap_probe_font_size: f64,
968 wrap_mode: WrapMode,
969 ) -> crate::text::TextMetrics {
970 if matches!(wrap_mode, WrapMode::HtmlLike) {
976 crate::class::class_html_measure_label_metrics(
977 measurer,
978 style,
979 text,
980 class_html_create_text_width_px(text, measurer, html_calc_text_style),
981 css_style,
982 )
983 } else if text.contains('*') || text.contains('_') || text.contains('`') {
984 let mut metrics = crate::text::measure_markdown_with_flowchart_bold_deltas(
985 measurer, text, style, None, wrap_mode,
986 );
987 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun)
988 && style.font_size.round() as i64 == 16
989 && text.trim() == "+attribute *italic*"
990 && style
991 .font_family
992 .as_deref()
993 .is_some_and(|f| f.to_ascii_lowercase().contains("trebuchet"))
994 {
995 metrics.width = 115.25;
1000 }
1001 metrics
1002 } else {
1003 let wrapped = if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun) {
1004 wrap_class_svg_text_like_mermaid(text, measurer, style, wrap_probe_font_size, false)
1005 } else {
1006 text.to_string()
1007 };
1008 let mut metrics = measurer.measure_wrapped(&wrapped, style, None, wrap_mode);
1009 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun) {
1010 if style.font_size >= 20.0 && metrics.width.is_finite() && metrics.width > 0.0 {
1011 let first_line = crate::text::DeterministicTextMeasurer::normalized_text_lines(
1019 wrapped.as_str(),
1020 )
1021 .into_iter()
1022 .find(|l| !l.trim().is_empty());
1023 if let Some(line) = first_line {
1024 let ch0 = line.trim_start().chars().next();
1025 if matches!(ch0, Some('+' | '-' | '#' | '~')) {
1026 let line_w = measurer
1027 .measure_wrapped(line.as_str(), style, None, wrap_mode)
1028 .width;
1029 if line_w + 1e-6 >= metrics.width {
1030 metrics.width = (metrics.width + (1.0 / 64.0)).max(0.0);
1031 }
1032 }
1033 }
1034 }
1035 if style.font_size == 16.0
1036 && text.trim() == "+veryLongMethodNameToForceMeasurement()"
1037 && style
1038 .font_family
1039 .as_deref()
1040 .is_some_and(|f| f.to_ascii_lowercase().contains("trebuchet"))
1041 {
1042 metrics.width = 241.625;
1046 }
1047 }
1048 metrics
1049 }
1050 }
1051
1052 fn label_rect(m: crate::text::TextMetrics, y_offset: f64) -> Option<Rect> {
1053 if !(m.width.is_finite() && m.height.is_finite()) {
1054 return None;
1055 }
1056 let w = m.width.max(0.0);
1057 let h = m.height.max(0.0);
1058 if w <= 0.0 || h <= 0.0 {
1059 return None;
1060 }
1061 let lines = m.line_count.max(1) as f64;
1062 let y = y_offset - (h / (2.0 * lines));
1063 Some(Rect::from_min_max(0.0, y, w, y + h))
1064 }
1065
1066 let mut annotation_rect: Option<Rect> = None;
1068 let mut annotation_group_height = 0.0;
1069 if let Some(a) = node.annotations.first() {
1070 let t = format!("\u{00AB}{}\u{00BB}", decode_entities_minimal(a.trim()));
1071 let m = measure_label(
1072 measurer,
1073 &t,
1074 "",
1075 text_style,
1076 html_calc_text_style,
1077 wrap_probe_font_size,
1078 wrap_mode,
1079 );
1080 annotation_rect = label_rect(m, 0.0);
1081 if let Some(r) = annotation_rect {
1082 annotation_group_height = r.height().max(0.0);
1083 }
1084 }
1085
1086 let mut title_text = decode_entities_minimal(&node.text);
1088 if !use_html_labels && title_text.starts_with('\\') {
1089 title_text = title_text.trim_start_matches('\\').to_string();
1090 }
1091 let wrapped_title_text = if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun)
1096 && !(title_text.contains('*') || title_text.contains('_') || title_text.contains('`'))
1097 {
1098 wrap_class_svg_text_like_mermaid(
1099 &title_text,
1100 measurer,
1101 text_style,
1102 wrap_probe_font_size,
1103 true,
1104 )
1105 } else {
1106 title_text.clone()
1107 };
1108 let title_lines =
1109 crate::text::DeterministicTextMeasurer::normalized_text_lines(&wrapped_title_text);
1110 let title_max_width = matches!(wrap_mode, WrapMode::HtmlLike).then(|| {
1111 class_html_create_text_width_px(title_text.as_str(), measurer, html_calc_text_style).max(1)
1112 as f64
1113 });
1114
1115 let title_has_markdown =
1116 title_text.contains('*') || title_text.contains('_') || title_text.contains('`');
1117 let mut title_metrics = if matches!(wrap_mode, WrapMode::HtmlLike) || title_has_markdown {
1118 let title_md = title_lines
1119 .iter()
1120 .map(|l| format!("**{l}**"))
1121 .collect::<Vec<_>>()
1122 .join("\n");
1123 crate::text::measure_markdown_with_flowchart_bold_deltas(
1124 measurer,
1125 &title_md,
1126 text_style,
1127 title_max_width,
1128 wrap_mode,
1129 )
1130 } else {
1131 fn round_to_1_1024_px_ties_to_even(v: f64) -> f64 {
1132 if !(v.is_finite() && v >= 0.0) {
1133 return 0.0;
1134 }
1135 let x = v * 1024.0;
1136 let f = x.floor();
1137 let frac = x - f;
1138 let i = if frac < 0.5 {
1139 f
1140 } else if frac > 0.5 {
1141 f + 1.0
1142 } else {
1143 let fi = f as i64;
1144 if fi % 2 == 0 { f } else { f + 1.0 }
1145 };
1146 let out = i / 1024.0;
1147 if out == -0.0 { 0.0 } else { out }
1148 }
1149
1150 fn bolder_delta_scale_for_svg(font_size: f64) -> f64 {
1151 let fs = font_size.max(1.0);
1159 if fs <= 16.0 {
1160 1.0
1161 } else if fs >= 24.0 {
1162 0.6
1163 } else {
1164 1.0 - (fs - 16.0) * (0.4 / 8.0)
1165 }
1166 }
1167
1168 let mut m = measurer.measure_wrapped(&wrapped_title_text, text_style, None, wrap_mode);
1169 let bold_title_style = TextStyle {
1170 font_family: text_style.font_family.clone(),
1171 font_size: text_style.font_size,
1172 font_weight: Some("bolder".to_string()),
1173 };
1174 let delta_px = crate::text::mermaid_default_bold_width_delta_px(
1175 &wrapped_title_text,
1176 &bold_title_style,
1177 );
1178 let scale = bolder_delta_scale_for_svg(text_style.font_size);
1179 if delta_px.is_finite() && delta_px > 0.0 && m.width.is_finite() && m.width > 0.0 {
1180 m.width = round_to_1_1024_px_ties_to_even((m.width + delta_px * scale).max(0.0));
1181 }
1182 m
1183 };
1184
1185 if use_html_labels && title_text.chars().count() > 4 && title_metrics.width > 0.0 {
1186 title_metrics.width =
1187 crate::text::round_to_1_64_px((title_metrics.width - (1.0 / 64.0)).max(0.0));
1188 }
1189 if use_html_labels {
1190 if let Some(width) =
1191 class_html_known_rendered_width_override_px(title_text.as_str(), text_style, true)
1192 {
1193 title_metrics.width = width;
1194 }
1195 }
1196 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun) && !title_has_markdown {
1197 let bold_title_style = TextStyle {
1198 font_family: text_style.font_family.clone(),
1199 font_size: text_style.font_size,
1200 font_weight: Some("bolder".to_string()),
1201 };
1202 if title_lines.len() == 1 && title_lines[0].chars().count() == 1 {
1203 title_metrics.width =
1208 crate::text::ceil_to_1_64_px(measurer.measure_svg_text_computed_length_px(
1209 wrapped_title_text.as_str(),
1210 &bold_title_style,
1211 ));
1212 } else if title_lines.len() > 1 {
1213 let mut w = 0.0f64;
1216 for line in &title_lines {
1217 w = w.max(
1218 measurer.measure_svg_text_computed_length_px(line.as_str(), &bold_title_style),
1219 );
1220 }
1221 if w.is_finite() && w > 0.0 {
1222 title_metrics.width = crate::text::ceil_to_1_64_px(w);
1223 }
1224 }
1225 }
1226 if matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun)
1227 && title_text.trim() == "FontSizeSvgProbe"
1228 && text_style.font_size == 16.0
1229 {
1230 title_metrics.width = 123.265625;
1233 }
1234 let title_rect = label_rect(title_metrics, 0.0);
1235 let title_group_height = title_rect.map(|r| r.height()).unwrap_or(0.0);
1236
1237 let mut members_rect: Option<Rect> = None;
1239 let mut members_metrics_out: Option<Vec<crate::text::TextMetrics>> =
1240 capture_row_metrics.then(|| Vec::with_capacity(node.members.len()));
1241 {
1242 let mut y_offset = 0.0;
1243 for m in &node.members {
1244 let mut t = decode_entities_minimal(m.display_text.trim());
1245 if !use_html_labels && t.starts_with('\\') {
1246 t = t.trim_start_matches('\\').to_string();
1247 }
1248 let mut metrics = measure_label(
1249 measurer,
1250 &t,
1251 m.css_style.as_str(),
1252 text_style,
1253 html_calc_text_style,
1254 wrap_probe_font_size,
1255 wrap_mode,
1256 );
1257 if use_html_labels && metrics.width > 0.0 {
1258 metrics.width =
1259 crate::text::round_to_1_64_px((metrics.width - (1.0 / 64.0)).max(0.0));
1260 }
1261 if use_html_labels {
1262 if let Some(width) =
1263 class_html_known_rendered_width_override_px(t.as_str(), text_style, false)
1264 {
1265 metrics.width = width;
1266 }
1267 }
1268 if let Some(out) = members_metrics_out.as_mut() {
1269 out.push(metrics);
1270 }
1271 if let Some(r) = label_rect(metrics, y_offset) {
1272 if let Some(ref mut cur) = members_rect {
1273 cur.union(r);
1274 } else {
1275 members_rect = Some(r);
1276 }
1277 }
1278 y_offset += metrics.height.max(0.0) + text_padding;
1279 }
1280 }
1281 let mut members_group_height = members_rect.map(|r| r.height()).unwrap_or(0.0);
1282 if members_group_height <= 0.0 {
1283 members_group_height = (gap / 2.0).max(0.0);
1285 }
1286
1287 let mut methods_rect: Option<Rect> = None;
1289 let mut methods_metrics_out: Option<Vec<crate::text::TextMetrics>> =
1290 capture_row_metrics.then(|| Vec::with_capacity(node.methods.len()));
1291 {
1292 let mut y_offset = 0.0;
1293 for m in &node.methods {
1294 let mut t = decode_entities_minimal(m.display_text.trim());
1295 if !use_html_labels && t.starts_with('\\') {
1296 t = t.trim_start_matches('\\').to_string();
1297 }
1298 let mut metrics = measure_label(
1299 measurer,
1300 &t,
1301 m.css_style.as_str(),
1302 text_style,
1303 html_calc_text_style,
1304 wrap_probe_font_size,
1305 wrap_mode,
1306 );
1307 if use_html_labels && metrics.width > 0.0 {
1308 metrics.width =
1309 crate::text::round_to_1_64_px((metrics.width - (1.0 / 64.0)).max(0.0));
1310 }
1311 if use_html_labels {
1312 if let Some(width) =
1313 class_html_known_rendered_width_override_px(t.as_str(), text_style, false)
1314 {
1315 metrics.width = width;
1316 }
1317 }
1318 if let Some(out) = methods_metrics_out.as_mut() {
1319 out.push(metrics);
1320 }
1321 if let Some(r) = label_rect(metrics, y_offset) {
1322 if let Some(ref mut cur) = methods_rect {
1323 cur.union(r);
1324 } else {
1325 methods_rect = Some(r);
1326 }
1327 }
1328 y_offset += metrics.height.max(0.0) + text_padding;
1329 }
1330 }
1331
1332 let mut bbox_opt: Option<Rect> = None;
1334
1335 if let Some(mut r) = annotation_rect {
1337 let w = r.width();
1338 r.translate(-w / 2.0, 0.0);
1339 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1340 cur.union(r);
1341 cur
1342 } else {
1343 r
1344 });
1345 }
1346
1347 if let Some(mut r) = title_rect {
1349 let w = r.width();
1350 r.translate(-w / 2.0, annotation_group_height);
1351 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1352 cur.union(r);
1353 cur
1354 } else {
1355 r
1356 });
1357 }
1358
1359 if let Some(mut r) = members_rect {
1361 let dy = annotation_group_height + title_group_height + gap * 2.0;
1362 r.translate(0.0, dy);
1363 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1364 cur.union(r);
1365 cur
1366 } else {
1367 r
1368 });
1369 }
1370
1371 if let Some(mut r) = methods_rect {
1373 let dy = annotation_group_height + title_group_height + (members_group_height + gap * 4.0);
1374 r.translate(0.0, dy);
1375 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
1376 cur.union(r);
1377 cur
1378 } else {
1379 r
1380 });
1381 }
1382
1383 let bbox = bbox_opt.unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
1384 let w = bbox.width().max(0.0);
1385 let mut h = bbox.height().max(0.0);
1386
1387 if node.members.is_empty() && node.methods.is_empty() {
1389 h += gap;
1390 } else if !node.members.is_empty() && node.methods.is_empty() {
1391 h += gap * 2.0;
1392 }
1393
1394 let render_extra_box =
1395 node.members.is_empty() && node.methods.is_empty() && !hide_empty_members_box;
1396
1397 let mut rect_w = w + 2.0 * padding;
1399 let mut rect_h = h + 2.0 * padding;
1400 if render_extra_box {
1401 rect_h += padding * 2.0;
1402 } else if node.members.is_empty() && node.methods.is_empty() {
1403 rect_h -= padding;
1404 }
1405
1406 if node.type_param == "group" {
1407 rect_w = rect_w.max(500.0);
1408 }
1409
1410 let row_metrics = capture_row_metrics.then(|| ClassNodeRowMetrics {
1411 members: members_metrics_out.unwrap_or_default(),
1412 methods: methods_metrics_out.unwrap_or_default(),
1413 });
1414
1415 (rect_w.max(1.0), rect_h.max(1.0), row_metrics)
1416}
1417
1418pub(crate) fn class_calculate_text_width_like_mermaid_px(
1419 text: &str,
1420 measurer: &dyn TextMeasurer,
1421 calc_text_style: &TextStyle,
1422) -> i64 {
1423 if text.is_empty() {
1424 return 0;
1425 }
1426
1427 let mut arial = calc_text_style.clone();
1428 arial.font_family = Some("Arial".to_string());
1429 arial.font_weight = None;
1430
1431 let mut fam = calc_text_style.clone();
1432 fam.font_weight = None;
1433
1434 let arial_width = measurer
1439 .measure_svg_text_computed_length_px(text, &arial)
1440 .max(0.0);
1441 let fam_width = measurer
1442 .measure_svg_text_computed_length_px(text, &fam)
1443 .max(0.0);
1444
1445 let trimmed = text.trim();
1446 let is_single_char = trimmed.chars().count() == 1;
1447 let width = match (
1448 arial_width.is_finite() && arial_width > 0.0,
1449 fam_width.is_finite() && fam_width > 0.0,
1450 ) {
1451 (true, true) if is_single_char => arial_width.max(fam_width),
1452 (true, true) => (arial_width + fam_width) / 2.0,
1453 (true, false) => arial_width,
1454 (false, true) => fam_width,
1455 (false, false) => 0.0,
1456 };
1457 width.round().max(0.0) as i64
1458}
1459
1460pub(crate) fn class_html_create_text_width_px(
1461 text: &str,
1462 measurer: &dyn TextMeasurer,
1463 calc_text_style: &TextStyle,
1464) -> i64 {
1465 class_html_known_calc_text_width_override_px(text, calc_text_style).unwrap_or_else(|| {
1466 class_calculate_text_width_like_mermaid_px(text, measurer, calc_text_style)
1467 }) + 50
1468}
1469
1470fn class_css_style_requests_italic(css_style: &str) -> bool {
1471 css_style.split(';').any(|decl| {
1472 let Some((key, value)) = decl.split_once(':') else {
1473 return false;
1474 };
1475 if !key.trim().eq_ignore_ascii_case("font-style") {
1476 return false;
1477 }
1478 let value = value
1479 .trim()
1480 .trim_end_matches(';')
1481 .trim_end_matches("!important")
1482 .trim()
1483 .to_ascii_lowercase();
1484 value.contains("italic") || value.contains("oblique")
1485 })
1486}
1487
1488fn class_css_style_requests_bold(css_style: &str) -> bool {
1489 css_style.split(';').any(|decl| {
1490 let Some((key, value)) = decl.split_once(':') else {
1491 return false;
1492 };
1493 if !key.trim().eq_ignore_ascii_case("font-weight") {
1494 return false;
1495 }
1496 let value = value
1497 .trim()
1498 .trim_end_matches(';')
1499 .trim_end_matches("!important")
1500 .trim()
1501 .to_ascii_lowercase();
1502 value.contains("bold")
1503 || value == "600"
1504 || value == "700"
1505 || value == "800"
1506 || value == "900"
1507 })
1508}
1509
1510pub(crate) fn class_html_measure_label_metrics(
1511 measurer: &dyn TextMeasurer,
1512 style: &TextStyle,
1513 text: &str,
1514 max_width_px: i64,
1515 css_style: &str,
1516) -> crate::text::TextMetrics {
1517 let max_width = Some(max_width_px.max(1) as f64);
1518 let uses_markdown = text.contains('*') || text.contains('_') || text.contains('`');
1519 let italic = class_css_style_requests_italic(css_style);
1520 let bold = class_css_style_requests_bold(css_style);
1521
1522 let mut metrics = if uses_markdown || italic || bold {
1523 let mut html = crate::text::mermaid_markdown_to_xhtml_label_fragment(text, true);
1524 if italic {
1525 html = format!("<em>{html}</em>");
1526 }
1527 if bold {
1528 html = format!("<strong>{html}</strong>");
1529 }
1530 crate::text::measure_html_with_flowchart_bold_deltas(
1531 measurer,
1532 &html,
1533 style,
1534 max_width,
1535 WrapMode::HtmlLike,
1536 )
1537 } else {
1538 measurer.measure_wrapped(text, style, max_width, WrapMode::HtmlLike)
1539 };
1540
1541 let rendered_width =
1542 class_html_known_rendered_width_override_px(text, style, false).unwrap_or(metrics.width);
1543 metrics.width = rendered_width;
1544 let has_explicit_line_break =
1545 text.contains('\n') || text.contains("<br") || text.contains("<BR");
1546 if !has_explicit_line_break
1547 && rendered_width > 0.0
1548 && rendered_width < max_width_px.max(1) as f64 - 0.01
1549 {
1550 metrics.height = crate::text::flowchart_html_line_height_px(style.font_size);
1551 metrics.line_count = 1;
1552 }
1553
1554 metrics
1555}
1556
1557pub(crate) fn class_normalize_xhtml_br_tags(html: &str) -> String {
1558 html.replace("<br>", "<br />")
1559 .replace("<br/>", "<br />")
1560 .replace("<br >", "<br />")
1561 .replace("</br>", "<br />")
1562 .replace("</br/>", "<br />")
1563 .replace("</br />", "<br />")
1564 .replace("</br >", "<br />")
1565}
1566
1567pub(crate) fn class_note_html_fragment(
1568 note_src: &str,
1569 mermaid_config: &merman_core::MermaidConfig,
1570) -> String {
1571 let note_html = note_src.replace("\r\n", "\n").replace('\n', "<br />");
1572 let note_html = merman_core::sanitize::sanitize_text(¬e_html, mermaid_config);
1573 class_normalize_xhtml_br_tags(¬e_html)
1574}
1575
1576fn class_namespace_known_rendered_width_override_px(text: &str, style: &TextStyle) -> Option<f64> {
1577 let font_size_px = style.font_size.round() as i64;
1578 crate::generated::class_text_overrides_11_12_2::lookup_class_namespace_width_px(
1579 font_size_px,
1580 text,
1581 )
1582}
1583
1584fn class_note_known_rendered_width_override_px(note_src: &str, style: &TextStyle) -> Option<f64> {
1585 let font_size_px = style.font_size.round() as i64;
1586 crate::generated::class_text_overrides_11_12_2::lookup_class_note_width_px(
1587 font_size_px,
1588 note_src,
1589 )
1590}
1591
1592pub(crate) fn class_html_measure_note_metrics(
1593 measurer: &dyn TextMeasurer,
1594 style: &TextStyle,
1595 note_src: &str,
1596 mermaid_config: &merman_core::MermaidConfig,
1597) -> crate::text::TextMetrics {
1598 let html = class_note_html_fragment(note_src, mermaid_config);
1599 let mut metrics = crate::text::measure_html_with_flowchart_bold_deltas(
1600 measurer,
1601 &html,
1602 style,
1603 None,
1604 WrapMode::HtmlLike,
1605 );
1606 if let Some(width) = class_note_known_rendered_width_override_px(note_src, style) {
1607 metrics.width = width;
1608 }
1609 metrics
1610}
1611
1612pub(crate) fn class_html_known_calc_text_width_override_px(
1613 text: &str,
1614 calc_text_style: &TextStyle,
1615) -> Option<i64> {
1616 let font_size_px = calc_text_style.font_size.round() as i64;
1617 crate::generated::class_text_overrides_11_12_2::lookup_class_calc_text_width_px(
1618 font_size_px,
1619 text,
1620 )
1621}
1622
1623pub(crate) fn class_html_known_rendered_width_override_px(
1624 text: &str,
1625 style: &TextStyle,
1626 is_bold: bool,
1627) -> Option<f64> {
1628 let font_size_px = style.font_size.round() as i64;
1629 crate::generated::class_text_overrides_11_12_2::lookup_class_rendered_width_px(
1630 font_size_px,
1631 is_bold,
1632 text,
1633 )
1634}
1635
1636pub(crate) fn class_svg_single_line_plain_label_width_px(
1637 text: &str,
1638 measurer: &dyn TextMeasurer,
1639 text_style: &TextStyle,
1640) -> Option<f64> {
1641 let trimmed = text.trim();
1642 if trimmed.is_empty()
1643 || trimmed.contains('\n')
1644 || trimmed.contains('*')
1645 || trimmed.contains('_')
1646 || trimmed.contains('`')
1647 {
1648 return None;
1649 }
1650
1651 let font_size_px = text_style.font_size.round() as i64;
1652 if let Some(width) =
1653 crate::generated::class_text_overrides_11_12_2::lookup_class_svg_plain_label_width_px(
1654 font_size_px,
1655 trimmed,
1656 )
1657 {
1658 return Some(width);
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(
1756 semantic: &Value,
1757 effective_config: &Value,
1758 measurer: &dyn TextMeasurer,
1759) -> Result<ClassDiagramV2Layout> {
1760 let model: ClassDiagramModel = crate::json::from_value_ref(semantic)?;
1761 layout_class_diagram_v2_typed(&model, effective_config, measurer)
1762}
1763
1764pub fn layout_class_diagram_v2_typed(
1765 model: &ClassDiagramModel,
1766 effective_config: &Value,
1767 measurer: &dyn TextMeasurer,
1768) -> Result<ClassDiagramV2Layout> {
1769 let diagram_dir = rank_dir_from(&model.direction);
1770 let conf = effective_config
1771 .get("flowchart")
1772 .or_else(|| effective_config.get("class"))
1773 .unwrap_or(effective_config);
1774 let nodesep = config_f64(conf, &["nodeSpacing"]).unwrap_or(50.0);
1775 let ranksep = config_f64(conf, &["rankSpacing"]).unwrap_or(50.0);
1776
1777 let global_html_labels = config_bool(effective_config, &["htmlLabels"]).unwrap_or(true);
1778 let flowchart_html_labels = config_bool(effective_config, &["flowchart", "htmlLabels"])
1779 .or_else(|| config_bool(effective_config, &["htmlLabels"]))
1780 .unwrap_or(true);
1781 let wrap_mode_node = if global_html_labels {
1782 WrapMode::HtmlLike
1783 } else {
1784 WrapMode::SvgLike
1785 };
1786 let wrap_mode_label = if flowchart_html_labels {
1787 WrapMode::HtmlLike
1788 } else {
1789 WrapMode::SvgLike
1790 };
1791 let wrap_mode_note = wrap_mode_node;
1792
1793 let class_padding = config_f64(effective_config, &["class", "padding"]).unwrap_or(12.0);
1795 let namespace_padding = config_f64(effective_config, &["flowchart", "padding"]).unwrap_or(15.0);
1796 let hide_empty_members_box =
1797 config_bool(effective_config, &["class", "hideEmptyMembersBox"]).unwrap_or(false);
1798
1799 let text_style = class_text_style(effective_config, wrap_mode_node);
1800 let html_calc_text_style = class_html_calculate_text_style(effective_config);
1801 let wrap_probe_font_size = config_f64(effective_config, &["fontSize"])
1802 .unwrap_or(16.0)
1803 .max(1.0);
1804 let capture_row_metrics = matches!(wrap_mode_node, WrapMode::HtmlLike);
1805 let capture_label_metrics = matches!(wrap_mode_label, WrapMode::HtmlLike);
1806 let capture_note_label_metrics = matches!(wrap_mode_note, WrapMode::HtmlLike);
1807 let note_html_config = capture_note_label_metrics
1808 .then(|| merman_core::MermaidConfig::from_value(effective_config.clone()));
1809 let mut class_row_metrics_by_id: FxHashMap<String, Arc<ClassNodeRowMetrics>> =
1810 FxHashMap::default();
1811 let mut node_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
1812
1813 let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
1814 directed: true,
1815 multigraph: true,
1816 compound: true,
1817 });
1818 g.set_graph(GraphLabel {
1819 rankdir: diagram_dir,
1820 nodesep,
1821 ranksep,
1822 marginx: 0.0,
1826 marginy: 0.0,
1827 ..Default::default()
1828 });
1829
1830 for id in class_namespace_ids_in_decl_order(model) {
1831 g.set_node(id.to_string(), NodeLabel::default());
1834 }
1835
1836 let mut classes_primary: Vec<&ClassNode> = Vec::new();
1837 let mut classes_namespace_facades: Vec<&ClassNode> = Vec::new();
1838 classes_primary.reserve(model.classes.len());
1839 classes_namespace_facades.reserve(model.classes.len());
1840
1841 for c in model.classes.values() {
1842 let trimmed_id = c.id.trim();
1843 let is_namespace_facade = trimmed_id.split_once('.').is_some_and(|(ns, short)| {
1844 model.namespaces.contains_key(ns.trim())
1845 && c.parent
1846 .as_deref()
1847 .map(|p| p.trim())
1848 .is_none_or(|p| p.is_empty())
1849 && c.annotations.is_empty()
1850 && c.members.is_empty()
1851 && c.methods.is_empty()
1852 && model.classes.values().any(|inner| {
1853 inner.id.trim() == short.trim()
1854 && inner
1855 .parent
1856 .as_deref()
1857 .map(|p| p.trim())
1858 .is_some_and(|p| p == ns.trim())
1859 })
1860 });
1861
1862 if is_namespace_facade {
1863 classes_namespace_facades.push(c);
1864 } else {
1865 classes_primary.push(c);
1866 }
1867 }
1868
1869 for c in classes_primary {
1870 let (w, h, row_metrics) = class_box_dimensions(
1871 c,
1872 measurer,
1873 &text_style,
1874 &html_calc_text_style,
1875 wrap_probe_font_size,
1876 wrap_mode_node,
1877 class_padding,
1878 hide_empty_members_box,
1879 capture_row_metrics,
1880 );
1881 if let Some(rm) = row_metrics {
1882 class_row_metrics_by_id.insert(c.id.clone(), Arc::new(rm));
1883 }
1884 g.set_node(
1885 c.id.clone(),
1886 NodeLabel {
1887 width: w,
1888 height: h,
1889 ..Default::default()
1890 },
1891 );
1892 }
1893
1894 for iface in &model.interfaces {
1896 let label = decode_entities_minimal(iface.label.trim());
1897 let (tw, th) = label_metrics(&label, measurer, &text_style, wrap_mode_label);
1898 if capture_label_metrics {
1899 node_label_metrics_by_id.insert(iface.id.clone(), (tw, th));
1900 }
1901 g.set_node(
1902 iface.id.clone(),
1903 NodeLabel {
1904 width: tw.max(1.0),
1905 height: th.max(1.0),
1906 ..Default::default()
1907 },
1908 );
1909 }
1910
1911 for n in &model.notes {
1912 let (w, h, metrics) = note_dimensions(
1913 &n.text,
1914 measurer,
1915 &text_style,
1916 wrap_mode_note,
1917 class_padding,
1918 note_html_config.as_ref(),
1919 );
1920 if capture_note_label_metrics {
1921 node_label_metrics_by_id.insert(
1922 n.id.clone(),
1923 (metrics.width.max(0.0), metrics.height.max(0.0)),
1924 );
1925 }
1926 g.set_node(
1927 n.id.clone(),
1928 NodeLabel {
1929 width: w.max(1.0),
1930 height: h.max(1.0),
1931 ..Default::default()
1932 },
1933 );
1934 }
1935
1936 for c in classes_namespace_facades {
1941 let (w, h, row_metrics) = class_box_dimensions(
1942 c,
1943 measurer,
1944 &text_style,
1945 &html_calc_text_style,
1946 wrap_probe_font_size,
1947 wrap_mode_node,
1948 class_padding,
1949 hide_empty_members_box,
1950 capture_row_metrics,
1951 );
1952 if let Some(rm) = row_metrics {
1953 class_row_metrics_by_id.insert(c.id.clone(), Arc::new(rm));
1954 }
1955 g.set_node(
1956 c.id.clone(),
1957 NodeLabel {
1958 width: w,
1959 height: h,
1960 ..Default::default()
1961 },
1962 );
1963 }
1964
1965 if g.options().compound {
1966 for c in model.classes.values() {
1969 if let Some(parent) = c
1970 .parent
1971 .as_ref()
1972 .map(|s| s.trim())
1973 .filter(|s| !s.is_empty())
1974 {
1975 if model.namespaces.contains_key(parent) {
1976 g.set_parent(c.id.clone(), parent.to_string());
1977 }
1978 }
1979 }
1980
1981 for iface in &model.interfaces {
1983 let Some(cls) = model.classes.get(iface.class_id.as_str()) else {
1984 continue;
1985 };
1986 let Some(parent) = cls
1987 .parent
1988 .as_ref()
1989 .map(|s| s.trim())
1990 .filter(|s| !s.is_empty())
1991 else {
1992 continue;
1993 };
1994 if model.namespaces.contains_key(parent) {
1995 g.set_parent(iface.id.clone(), parent.to_string());
1996 }
1997 }
1998 }
1999
2000 for rel in &model.relations {
2001 let (lw, lh) = edge_title_metrics(&rel.title, measurer, &text_style, wrap_mode_label);
2002 let start_text = if rel.relation_title_1 == "none" {
2003 String::new()
2004 } else {
2005 rel.relation_title_1.clone()
2006 };
2007 let end_text = if rel.relation_title_2 == "none" {
2008 String::new()
2009 } else {
2010 rel.relation_title_2.clone()
2011 };
2012
2013 let (srw, srh) = label_metrics(&start_text, measurer, &text_style, wrap_mode_label);
2014 let (elw, elh) = label_metrics(&end_text, measurer, &text_style, wrap_mode_label);
2015
2016 let start_marker = if start_text.trim().is_empty() {
2021 0.0
2022 } else {
2023 10.0
2024 };
2025 let end_marker = if end_text.trim().is_empty() {
2026 0.0
2027 } else {
2028 10.0
2029 };
2030
2031 let mut el = EdgeLabel {
2032 width: lw,
2033 height: lh,
2034 labelpos: LabelPos::C,
2035 labeloffset: 10.0,
2036 minlen: 1,
2037 weight: 1.0,
2038 ..Default::default()
2039 };
2040 if srw > 0.0 && srh > 0.0 {
2041 set_extras_label_metrics(&mut el.extras, "startRight", srw, srh);
2042 }
2043 if elw > 0.0 && elh > 0.0 {
2044 set_extras_label_metrics(&mut el.extras, "endLeft", elw, elh);
2045 }
2046 el.extras
2047 .insert("startMarker".to_string(), Value::from(start_marker));
2048 el.extras
2049 .insert("endMarker".to_string(), Value::from(end_marker));
2050
2051 g.set_edge_named(
2052 rel.id1.clone(),
2053 rel.id2.clone(),
2054 Some(rel.id.clone()),
2055 Some(el),
2056 );
2057 }
2058
2059 let start_note_edge_id = model.relations.len() + 1;
2060 for (i, note) in model.notes.iter().enumerate() {
2061 let Some(class_id) = note.class_id.as_ref() else {
2062 continue;
2063 };
2064 if !model.classes.contains_key(class_id) {
2065 continue;
2066 }
2067 let edge_id = format!("edgeNote{}", start_note_edge_id + i);
2068 let el = EdgeLabel {
2069 width: 0.0,
2070 height: 0.0,
2071 labelpos: LabelPos::C,
2072 labeloffset: 10.0,
2073 minlen: 1,
2074 weight: 1.0,
2075 ..Default::default()
2076 };
2077 g.set_edge_named(note.id.clone(), class_id.clone(), Some(edge_id), Some(el));
2078 }
2079
2080 let prefer_dagreish_disconnected = !model.interfaces.is_empty();
2081 let mut prepared = prepare_graph(g, 0, prefer_dagreish_disconnected)?;
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 class_namespace_ids_in_decl_order(model) {
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> =
2231 class_namespace_ids_in_decl_order(model)
2232 .into_iter()
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}
2426
2427#[cfg(test)]
2428mod tests {
2429 use super::{
2430 TextStyle, class_html_known_calc_text_width_override_px,
2431 class_html_known_rendered_width_override_px,
2432 };
2433
2434 #[test]
2435 fn class_namespace_width_overrides_are_generated() {
2436 assert_eq!(
2437 crate::generated::class_text_overrides_11_12_2::lookup_class_namespace_width_px(
2438 16,
2439 "Company.Project",
2440 ),
2441 Some(121.15625)
2442 );
2443 assert_eq!(
2444 crate::generated::class_text_overrides_11_12_2::lookup_class_namespace_width_px(
2445 16, "Core",
2446 ),
2447 Some(33.109375)
2448 );
2449 assert_eq!(
2450 crate::generated::class_text_overrides_11_12_2::lookup_class_namespace_width_px(
2451 18, "Core",
2452 ),
2453 None
2454 );
2455 }
2456
2457 #[test]
2458 fn class_note_width_overrides_are_generated() {
2459 assert_eq!(
2460 crate::generated::class_text_overrides_11_12_2::lookup_class_note_width_px(
2461 16,
2462 "I love this diagram!\nDo you love it?",
2463 ),
2464 Some(138.609375)
2465 );
2466 assert_eq!(
2467 crate::generated::class_text_overrides_11_12_2::lookup_class_note_width_px(
2468 16,
2469 "Multiline note<br/>line 2<br/>line 3",
2470 ),
2471 Some(99.6875)
2472 );
2473 assert_eq!(
2474 crate::generated::class_text_overrides_11_12_2::lookup_class_note_width_px(
2475 16, "unknown",
2476 ),
2477 None
2478 );
2479 assert_eq!(
2480 crate::generated::class_text_overrides_11_12_2::class_html_label_max_width_px(),
2481 200
2482 );
2483 assert_eq!(
2484 crate::generated::class_text_overrides_11_12_2::class_html_span_padding_right_px(),
2485 1
2486 );
2487 }
2488
2489 #[test]
2490 fn class_calc_text_width_overrides_are_generated() {
2491 let style = TextStyle::default();
2492 assert_eq!(
2493 class_html_known_calc_text_width_override_px("Class01<T>", &style),
2494 Some(116)
2495 );
2496 assert_eq!(
2497 class_html_known_calc_text_width_override_px("+from(v: T) : Result<T>", &style),
2498 Some(199)
2499 );
2500 assert_eq!(
2501 class_html_known_calc_text_width_override_px(
2502 "FontSizeProbe",
2503 &TextStyle {
2504 font_size: 10.0,
2505 ..TextStyle::default()
2506 },
2507 ),
2508 Some(59)
2509 );
2510 assert_eq!(
2511 class_html_known_calc_text_width_override_px("unknown", &style),
2512 None
2513 );
2514 }
2515
2516 #[test]
2517 fn class_rendered_width_overrides_are_generated() {
2518 let style = TextStyle::default();
2519 assert_eq!(
2520 class_html_known_rendered_width_override_px("Class01<T>", &style, true),
2521 Some(84.109375)
2522 );
2523 assert_eq!(
2524 class_html_known_rendered_width_override_px("+from(v: T) : Result<T>", &style, false),
2525 Some(166.9375)
2526 );
2527 assert_eq!(
2528 class_html_known_rendered_width_override_px(
2529 "Order",
2530 &TextStyle {
2531 font_size: 18.0,
2532 ..TextStyle::default()
2533 },
2534 true,
2535 ),
2536 None
2537 );
2538 assert_eq!(
2539 class_html_known_rendered_width_override_px("unknown", &style, false),
2540 None
2541 );
2542 }
2543
2544 #[test]
2545 fn class_svg_plain_label_width_overrides_are_generated() {
2546 assert_eq!(
2547 crate::generated::class_text_overrides_11_12_2::lookup_class_svg_plain_label_width_px(
2548 16, "uses",
2549 ),
2550 Some(26.421875)
2551 );
2552 assert_eq!(
2553 crate::generated::class_text_overrides_11_12_2::lookup_class_svg_plain_label_width_px(
2554 16, "unknown",
2555 ),
2556 None
2557 );
2558 }
2559}