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