Skip to main content

merman_render/
gitgraph.rs

1use crate::Result;
2use crate::model::{
3    Bounds, GitGraphArrowLayout, GitGraphBranchLayout, GitGraphCommitLayout, GitGraphDiagramLayout,
4};
5use crate::text::{TextMeasurer, TextStyle};
6use serde::Deserialize;
7use std::collections::HashMap;
8
9const LAYOUT_OFFSET: f64 = 10.0;
10const COMMIT_STEP: f64 = 40.0;
11const DEFAULT_POS: f64 = 30.0;
12const THEME_COLOR_LIMIT: usize = 8;
13
14const COMMIT_TYPE_MERGE: i64 = 3;
15
16#[derive(Debug, Clone, Deserialize)]
17struct GitGraphBranch {
18    name: String,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22struct GitGraphCommit {
23    id: String,
24    #[serde(default)]
25    message: String,
26    #[serde(default)]
27    parents: Vec<String>,
28    seq: i64,
29    #[serde(default)]
30    tags: Vec<String>,
31    #[serde(rename = "type")]
32    commit_type: i64,
33    branch: String,
34    #[serde(default, rename = "customType")]
35    custom_type: Option<i64>,
36    #[serde(default, rename = "customId")]
37    custom_id: Option<bool>,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41struct GitGraphModel {
42    #[serde(default)]
43    branches: Vec<GitGraphBranch>,
44    #[serde(default)]
45    commits: Vec<GitGraphCommit>,
46    #[serde(default)]
47    direction: String,
48    #[serde(rename = "type")]
49    diagram_type: String,
50}
51
52fn cfg_f64(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
53    let mut cur = cfg;
54    for k in path {
55        cur = cur.get(*k)?;
56    }
57    cur.as_f64()
58}
59
60fn cfg_bool(cfg: &serde_json::Value, path: &[&str]) -> Option<bool> {
61    let mut cur = cfg;
62    for k in path {
63        cur = cur.get(*k)?;
64    }
65    cur.as_bool()
66}
67
68fn cfg_string(cfg: &serde_json::Value, path: &[&str]) -> Option<String> {
69    let mut cur = cfg;
70    for k in path {
71        cur = cur.get(*k)?;
72    }
73    cur.as_str().map(|s| s.to_string())
74}
75
76fn parse_css_px_to_f64(s: &str) -> Option<f64> {
77    let s = s.trim();
78    let raw = s.strip_suffix("px").unwrap_or(s).trim();
79    raw.parse::<f64>().ok().filter(|value| value.is_finite())
80}
81
82fn json_f64_css_px(v: &serde_json::Value) -> Option<f64> {
83    v.as_f64()
84        .or_else(|| v.as_i64().map(|n| n as f64))
85        .or_else(|| v.as_u64().map(|n| n as f64))
86        .or_else(|| v.as_str().and_then(parse_css_px_to_f64))
87}
88
89fn cfg_font_size(cfg: &serde_json::Value) -> f64 {
90    cfg.get("themeVariables")
91        .and_then(|v| v.get("fontSize"))
92        .and_then(json_f64_css_px)
93        .or_else(|| cfg.get("fontSize").and_then(json_f64_css_px))
94        .unwrap_or(16.0)
95        .max(1.0)
96}
97
98fn commit_symbol_type(commit: &GitGraphCommit) -> i64 {
99    commit.custom_type.unwrap_or(commit.commit_type)
100}
101
102#[derive(Debug, Clone, Copy)]
103struct CommitPosition {
104    x: f64,
105    y: f64,
106}
107
108fn find_closest_parent(
109    parents: &[String],
110    dir: &str,
111    commit_pos: &HashMap<String, CommitPosition>,
112) -> Option<String> {
113    let mut target: f64 = if dir == "BT" { f64::INFINITY } else { 0.0 };
114    let mut closest: Option<String> = None;
115    for parent in parents {
116        let Some(pos) = commit_pos.get(parent) else {
117            continue;
118        };
119        let parent_position = if dir == "TB" || dir == "BT" {
120            pos.y
121        } else {
122            pos.x
123        };
124        if dir == "BT" {
125            if parent_position <= target {
126                closest = Some(parent.clone());
127                target = parent_position;
128            }
129        } else if parent_position >= target {
130            closest = Some(parent.clone());
131            target = parent_position;
132        }
133    }
134    closest
135}
136
137fn should_reroute_arrow(
138    commit_a: &GitGraphCommit,
139    commit_b: &GitGraphCommit,
140    p1: CommitPosition,
141    p2: CommitPosition,
142    all_commits: &HashMap<String, GitGraphCommit>,
143    dir: &str,
144) -> bool {
145    let commit_b_is_furthest = if dir == "TB" || dir == "BT" {
146        p1.x < p2.x
147    } else {
148        p1.y < p2.y
149    };
150    let branch_to_get_curve = if commit_b_is_furthest {
151        commit_b.branch.as_str()
152    } else {
153        commit_a.branch.as_str()
154    };
155
156    all_commits.values().any(|commit_x| {
157        commit_x.branch == branch_to_get_curve
158            && commit_x.seq > commit_a.seq
159            && commit_x.seq < commit_b.seq
160    })
161}
162
163fn find_lane(y1: f64, y2: f64, lanes: &mut Vec<f64>, depth: usize) -> f64 {
164    let candidate = y1 + (y1 - y2).abs() / 2.0;
165    if depth > 5 {
166        return candidate;
167    }
168
169    let ok = lanes.iter().all(|lane| (lane - candidate).abs() >= 10.0);
170    if ok {
171        lanes.push(candidate);
172        return candidate;
173    }
174
175    let diff = (y1 - y2).abs();
176    find_lane(y1, y2 - diff / 5.0, lanes, depth + 1)
177}
178
179fn draw_arrow(
180    commit_a: &GitGraphCommit,
181    commit_b: &GitGraphCommit,
182    all_commits: &HashMap<String, GitGraphCommit>,
183    commit_pos: &HashMap<String, CommitPosition>,
184    branch_index: &HashMap<String, usize>,
185    lanes: &mut Vec<f64>,
186    dir: &str,
187) -> Option<GitGraphArrowLayout> {
188    let p1 = *commit_pos.get(&commit_a.id)?;
189    let p2 = *commit_pos.get(&commit_b.id)?;
190    let arrow_needs_rerouting = should_reroute_arrow(commit_a, commit_b, p1, p2, all_commits, dir);
191
192    let mut color_class_num = branch_index.get(&commit_b.branch).copied().unwrap_or(0);
193    if commit_b.commit_type == COMMIT_TYPE_MERGE
194        && commit_a
195            .id
196            .as_str()
197            .ne(commit_b.parents.first().map(|s| s.as_str()).unwrap_or(""))
198    {
199        color_class_num = branch_index
200            .get(&commit_a.branch)
201            .copied()
202            .unwrap_or(color_class_num);
203    }
204
205    let mut line_def: Option<String> = None;
206    if arrow_needs_rerouting {
207        let arc = "A 10 10, 0, 0, 0,";
208        let arc2 = "A 10 10, 0, 0, 1,";
209        let radius = 10.0;
210        let offset = 10.0;
211
212        let line_y = if p1.y < p2.y {
213            find_lane(p1.y, p2.y, lanes, 0)
214        } else {
215            find_lane(p2.y, p1.y, lanes, 0)
216        };
217        let line_x = if p1.x < p2.x {
218            find_lane(p1.x, p2.x, lanes, 0)
219        } else {
220            find_lane(p2.x, p1.x, lanes, 0)
221        };
222
223        if dir == "TB" {
224            if p1.x < p2.x {
225                line_def = Some(format!(
226                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
227                    p1.x,
228                    p1.y,
229                    line_x - radius,
230                    p1.y,
231                    arc2,
232                    line_x,
233                    p1.y + offset,
234                    line_x,
235                    p2.y - radius,
236                    arc,
237                    line_x + offset,
238                    p2.y,
239                    p2.x,
240                    p2.y
241                ));
242            } else {
243                color_class_num = branch_index.get(&commit_a.branch).copied().unwrap_or(0);
244                line_def = Some(format!(
245                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
246                    p1.x,
247                    p1.y,
248                    line_x + radius,
249                    p1.y,
250                    arc,
251                    line_x,
252                    p1.y + offset,
253                    line_x,
254                    p2.y - radius,
255                    arc2,
256                    line_x - offset,
257                    p2.y,
258                    p2.x,
259                    p2.y
260                ));
261            }
262        } else if dir == "BT" {
263            if p1.x < p2.x {
264                line_def = Some(format!(
265                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
266                    p1.x,
267                    p1.y,
268                    line_x - radius,
269                    p1.y,
270                    arc,
271                    line_x,
272                    p1.y - offset,
273                    line_x,
274                    p2.y + radius,
275                    arc2,
276                    line_x + offset,
277                    p2.y,
278                    p2.x,
279                    p2.y
280                ));
281            } else {
282                color_class_num = branch_index.get(&commit_a.branch).copied().unwrap_or(0);
283                line_def = Some(format!(
284                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
285                    p1.x,
286                    p1.y,
287                    line_x + radius,
288                    p1.y,
289                    arc2,
290                    line_x,
291                    p1.y - offset,
292                    line_x,
293                    p2.y + radius,
294                    arc,
295                    line_x - offset,
296                    p2.y,
297                    p2.x,
298                    p2.y
299                ));
300            }
301        } else if p1.y < p2.y {
302            line_def = Some(format!(
303                "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
304                p1.x,
305                p1.y,
306                p1.x,
307                line_y - radius,
308                arc,
309                p1.x + offset,
310                line_y,
311                p2.x - radius,
312                line_y,
313                arc2,
314                p2.x,
315                line_y + offset,
316                p2.x,
317                p2.y
318            ));
319        } else {
320            color_class_num = branch_index.get(&commit_a.branch).copied().unwrap_or(0);
321            line_def = Some(format!(
322                "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
323                p1.x,
324                p1.y,
325                p1.x,
326                line_y + radius,
327                arc2,
328                p1.x + offset,
329                line_y,
330                p2.x - radius,
331                line_y,
332                arc,
333                p2.x,
334                line_y - offset,
335                p2.x,
336                p2.y
337            ));
338        }
339    } else {
340        let arc = "A 20 20, 0, 0, 0,";
341        let arc2 = "A 20 20, 0, 0, 1,";
342        let radius = 20.0;
343        let offset = 20.0;
344
345        if dir == "TB" {
346            if p1.x < p2.x {
347                if commit_b.commit_type == COMMIT_TYPE_MERGE
348                    && commit_a.id.as_str().ne(commit_b
349                        .parents
350                        .first()
351                        .map(|s| s.as_str())
352                        .unwrap_or(""))
353                {
354                    line_def = Some(format!(
355                        "M {} {} L {} {} {} {} {} L {} {}",
356                        p1.x,
357                        p1.y,
358                        p1.x,
359                        p2.y - radius,
360                        arc,
361                        p1.x + offset,
362                        p2.y,
363                        p2.x,
364                        p2.y
365                    ));
366                } else {
367                    line_def = Some(format!(
368                        "M {} {} L {} {} {} {} {} L {} {}",
369                        p1.x,
370                        p1.y,
371                        p2.x - radius,
372                        p1.y,
373                        arc2,
374                        p2.x,
375                        p1.y + offset,
376                        p2.x,
377                        p2.y
378                    ));
379                }
380            }
381
382            if p1.x > p2.x {
383                if commit_b.commit_type == COMMIT_TYPE_MERGE
384                    && commit_a.id.as_str().ne(commit_b
385                        .parents
386                        .first()
387                        .map(|s| s.as_str())
388                        .unwrap_or(""))
389                {
390                    line_def = Some(format!(
391                        "M {} {} L {} {} {} {} {} L {} {}",
392                        p1.x,
393                        p1.y,
394                        p1.x,
395                        p2.y - radius,
396                        arc2,
397                        p1.x - offset,
398                        p2.y,
399                        p2.x,
400                        p2.y
401                    ));
402                } else {
403                    line_def = Some(format!(
404                        "M {} {} L {} {} {} {} {} L {} {}",
405                        p1.x,
406                        p1.y,
407                        p2.x + radius,
408                        p1.y,
409                        arc,
410                        p2.x,
411                        p1.y + offset,
412                        p2.x,
413                        p2.y
414                    ));
415                }
416            }
417
418            if p1.x == p2.x {
419                line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
420            }
421        } else if dir == "BT" {
422            if p1.x < p2.x {
423                if commit_b.commit_type == COMMIT_TYPE_MERGE
424                    && commit_a.id.as_str().ne(commit_b
425                        .parents
426                        .first()
427                        .map(|s| s.as_str())
428                        .unwrap_or(""))
429                {
430                    line_def = Some(format!(
431                        "M {} {} L {} {} {} {} {} L {} {}",
432                        p1.x,
433                        p1.y,
434                        p1.x,
435                        p2.y + radius,
436                        arc2,
437                        p1.x + offset,
438                        p2.y,
439                        p2.x,
440                        p2.y
441                    ));
442                } else {
443                    line_def = Some(format!(
444                        "M {} {} L {} {} {} {} {} L {} {}",
445                        p1.x,
446                        p1.y,
447                        p2.x - radius,
448                        p1.y,
449                        arc,
450                        p2.x,
451                        p1.y - offset,
452                        p2.x,
453                        p2.y
454                    ));
455                }
456            }
457
458            if p1.x > p2.x {
459                if commit_b.commit_type == COMMIT_TYPE_MERGE
460                    && commit_a.id.as_str().ne(commit_b
461                        .parents
462                        .first()
463                        .map(|s| s.as_str())
464                        .unwrap_or(""))
465                {
466                    line_def = Some(format!(
467                        "M {} {} L {} {} {} {} {} L {} {}",
468                        p1.x,
469                        p1.y,
470                        p1.x,
471                        p2.y + radius,
472                        arc,
473                        p1.x - offset,
474                        p2.y,
475                        p2.x,
476                        p2.y
477                    ));
478                } else {
479                    line_def = Some(format!(
480                        "M {} {} L {} {} {} {} {} L {} {}",
481                        p1.x,
482                        p1.y,
483                        p2.x - radius,
484                        p1.y,
485                        arc,
486                        p2.x,
487                        p1.y - offset,
488                        p2.x,
489                        p2.y
490                    ));
491                }
492            }
493
494            if p1.x == p2.x {
495                line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
496            }
497        } else {
498            if p1.y < p2.y {
499                if commit_b.commit_type == COMMIT_TYPE_MERGE
500                    && commit_a.id.as_str().ne(commit_b
501                        .parents
502                        .first()
503                        .map(|s| s.as_str())
504                        .unwrap_or(""))
505                {
506                    line_def = Some(format!(
507                        "M {} {} L {} {} {} {} {} L {} {}",
508                        p1.x,
509                        p1.y,
510                        p2.x - radius,
511                        p1.y,
512                        arc2,
513                        p2.x,
514                        p1.y + offset,
515                        p2.x,
516                        p2.y
517                    ));
518                } else {
519                    line_def = Some(format!(
520                        "M {} {} L {} {} {} {} {} L {} {}",
521                        p1.x,
522                        p1.y,
523                        p1.x,
524                        p2.y - radius,
525                        arc,
526                        p1.x + offset,
527                        p2.y,
528                        p2.x,
529                        p2.y
530                    ));
531                }
532            }
533
534            if p1.y > p2.y {
535                if commit_b.commit_type == COMMIT_TYPE_MERGE
536                    && commit_a.id.as_str().ne(commit_b
537                        .parents
538                        .first()
539                        .map(|s| s.as_str())
540                        .unwrap_or(""))
541                {
542                    line_def = Some(format!(
543                        "M {} {} L {} {} {} {} {} L {} {}",
544                        p1.x,
545                        p1.y,
546                        p2.x - radius,
547                        p1.y,
548                        arc,
549                        p2.x,
550                        p1.y - offset,
551                        p2.x,
552                        p2.y
553                    ));
554                } else {
555                    line_def = Some(format!(
556                        "M {} {} L {} {} {} {} {} L {} {}",
557                        p1.x,
558                        p1.y,
559                        p1.x,
560                        p2.y + radius,
561                        arc2,
562                        p1.x + offset,
563                        p2.y,
564                        p2.x,
565                        p2.y
566                    ));
567                }
568            }
569
570            if p1.y == p2.y {
571                line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
572            }
573        }
574    }
575
576    let d = line_def?;
577    Some(GitGraphArrowLayout {
578        from: commit_a.id.clone(),
579        to: commit_b.id.clone(),
580        class_index: (color_class_num % THEME_COLOR_LIMIT) as i64,
581        d,
582    })
583}
584
585pub fn layout_gitgraph_diagram(
586    semantic: &serde_json::Value,
587    effective_config: &serde_json::Value,
588    measurer: &dyn TextMeasurer,
589) -> Result<GitGraphDiagramLayout> {
590    let model: GitGraphModel = crate::json::from_value_ref(semantic)?;
591    let _ = model.diagram_type.as_str();
592
593    let direction = if model.direction.trim().is_empty() {
594        "LR".to_string()
595    } else {
596        model.direction.trim().to_string()
597    };
598
599    let rotate_commit_label =
600        cfg_bool(effective_config, &["gitGraph", "rotateCommitLabel"]).unwrap_or(true);
601    let show_commit_label =
602        cfg_bool(effective_config, &["gitGraph", "showCommitLabel"]).unwrap_or(true);
603    let show_branches = cfg_bool(effective_config, &["gitGraph", "showBranches"]).unwrap_or(true);
604    let diagram_padding = cfg_f64(effective_config, &["gitGraph", "diagramPadding"])
605        .unwrap_or(8.0)
606        .max(0.0);
607    let parallel_commits =
608        cfg_bool(effective_config, &["gitGraph", "parallelCommits"]).unwrap_or(false);
609
610    // Upstream gitGraph uses SVG `getBBox()` probes for branch label widths while the
611    // `drawText(...)` nodes inherit Mermaid's global font config.
612    let font_family = cfg_string(effective_config, &["fontFamily"])
613        .or_else(|| cfg_string(effective_config, &["themeVariables", "fontFamily"]))
614        .map(|s| s.trim().trim_end_matches(';').trim().to_string())
615        .filter(|s| !s.is_empty())
616        .unwrap_or_else(|| "\"trebuchet ms\", verdana, arial, sans-serif".to_string());
617    let font_size = cfg_font_size(effective_config);
618    let apply_bbox_corrections = crate::generated::gitgraph_text_overrides_11_12_2::
619        gitgraph_branch_label_bbox_corrections_enabled(&font_family, font_size);
620
621    let label_style = TextStyle {
622        font_family: Some(font_family),
623        font_size,
624        font_weight: None,
625    };
626
627    let mut branches: Vec<GitGraphBranchLayout> = Vec::new();
628    let mut branch_pos: HashMap<String, f64> = HashMap::new();
629    let mut branch_index: HashMap<String, usize> = HashMap::new();
630    let mut pos = 0.0;
631    for (i, b) in model.branches.iter().enumerate() {
632        // Upstream gitGraph uses `drawText(...).getBBox().width` for branch label widths.
633        let metrics = measurer.measure(&b.name, &label_style);
634        let bbox_w = crate::generated::gitgraph_text_overrides_11_12_2::
635            adjust_gitgraph_branch_label_bbox_width_px(
636                measurer.measure_svg_simple_text_bbox_width_px(&b.name, &label_style),
637            &b.name,
638                apply_bbox_corrections,
639            );
640        branch_pos.insert(b.name.clone(), pos);
641        branch_index.insert(b.name.clone(), i);
642
643        branches.push(GitGraphBranchLayout {
644            name: b.name.clone(),
645            index: i as i64,
646            pos,
647            bbox_width: bbox_w.max(0.0),
648            bbox_height: metrics.height.max(0.0),
649        });
650
651        pos += 50.0
652            + if rotate_commit_label { 40.0 } else { 0.0 }
653            + if direction == "TB" || direction == "BT" {
654                bbox_w.max(0.0) / 2.0
655            } else {
656                0.0
657            };
658    }
659
660    let mut commits_by_id: HashMap<String, GitGraphCommit> = HashMap::new();
661    for c in &model.commits {
662        commits_by_id.insert(c.id.clone(), c.clone());
663    }
664
665    let mut commit_order: Vec<GitGraphCommit> = model.commits.clone();
666    commit_order.sort_by_key(|c| c.seq);
667
668    let mut sorted_keys: Vec<String> = commit_order.iter().map(|c| c.id.clone()).collect();
669    if direction == "BT" {
670        sorted_keys.reverse();
671    }
672
673    let mut commit_pos: HashMap<String, CommitPosition> = HashMap::new();
674    let mut commits: Vec<GitGraphCommitLayout> = Vec::new();
675    let mut max_pos: f64 = 0.0;
676    let mut cur_pos = if direction == "TB" || direction == "BT" {
677        DEFAULT_POS
678    } else {
679        0.0
680    };
681
682    for id in &sorted_keys {
683        let Some(commit) = commits_by_id.get(id) else {
684            continue;
685        };
686
687        if parallel_commits {
688            if !commit.parents.is_empty() {
689                if let Some(closest_parent) =
690                    find_closest_parent(&commit.parents, &direction, &commit_pos)
691                {
692                    if let Some(parent_position) = commit_pos.get(&closest_parent) {
693                        if direction == "TB" {
694                            cur_pos = parent_position.y + COMMIT_STEP;
695                        } else if direction == "BT" {
696                            let current_position = commit_pos
697                                .get(&commit.id)
698                                .copied()
699                                .unwrap_or(CommitPosition { x: 0.0, y: 0.0 });
700                            cur_pos = current_position.y - COMMIT_STEP;
701                        } else {
702                            cur_pos = parent_position.x + COMMIT_STEP;
703                        }
704                    }
705                }
706            } else if direction == "TB" {
707                cur_pos = DEFAULT_POS;
708            }
709        }
710
711        let pos_with_offset = if direction == "BT" && parallel_commits {
712            cur_pos
713        } else {
714            cur_pos + LAYOUT_OFFSET
715        };
716        let Some(branch_lane) = branch_pos.get(&commit.branch).copied() else {
717            return Err(crate::Error::InvalidModel {
718                message: format!("unknown branch for commit {}: {}", commit.id, commit.branch),
719            });
720        };
721
722        let (x, y) = if direction == "TB" || direction == "BT" {
723            (branch_lane, pos_with_offset)
724        } else {
725            (pos_with_offset, branch_lane)
726        };
727        commit_pos.insert(commit.id.clone(), CommitPosition { x, y });
728
729        commits.push(GitGraphCommitLayout {
730            id: commit.id.clone(),
731            message: commit.message.clone(),
732            seq: commit.seq,
733            commit_type: commit.commit_type,
734            custom_type: commit.custom_type,
735            custom_id: commit.custom_id,
736            tags: commit.tags.clone(),
737            parents: commit.parents.clone(),
738            branch: commit.branch.clone(),
739            pos: cur_pos,
740            pos_with_offset,
741            x,
742            y,
743        });
744
745        cur_pos = if direction == "BT" && parallel_commits {
746            cur_pos + COMMIT_STEP
747        } else {
748            cur_pos + COMMIT_STEP + LAYOUT_OFFSET
749        };
750        max_pos = max_pos.max(cur_pos);
751    }
752
753    let mut lanes: Vec<f64> = if show_branches {
754        branches.iter().map(|b| b.pos).collect()
755    } else {
756        Vec::new()
757    };
758
759    let mut arrows: Vec<GitGraphArrowLayout> = Vec::new();
760    // Mermaid draws arrows by iterating insertion order of the commits map. The DB inserts commits
761    // in sequence order, so iterate by `seq` regardless of direction.
762    let mut commits_for_arrows = model.commits.clone();
763    commits_for_arrows.sort_by_key(|c| c.seq);
764    for commit_b in &commits_for_arrows {
765        for parent in &commit_b.parents {
766            let Some(commit_a) = commits_by_id.get(parent) else {
767                continue;
768            };
769            if let Some(a) = draw_arrow(
770                commit_a,
771                commit_b,
772                &commits_by_id,
773                &commit_pos,
774                &branch_index,
775                &mut lanes,
776                &direction,
777            ) {
778                arrows.push(a);
779            }
780        }
781    }
782
783    let mut min_x = f64::INFINITY;
784    let mut min_y = f64::INFINITY;
785    let mut max_x = f64::NEG_INFINITY;
786    let mut max_y = f64::NEG_INFINITY;
787
788    for b in &branches {
789        if direction == "TB" || direction == "BT" {
790            min_x = min_x.min(b.pos);
791            max_x = max_x.max(b.pos);
792            min_y = min_y.min(DEFAULT_POS.min(max_pos));
793            max_y = max_y.max(DEFAULT_POS.max(max_pos));
794        } else {
795            min_y = min_y.min(b.pos);
796            max_y = max_y.max(b.pos);
797            min_x = min_x.min(0.0);
798            max_x = max_x.max(max_pos);
799            let label_left =
800                -b.bbox_width - 4.0 - if rotate_commit_label { 30.0 } else { 0.0 } - 19.0;
801            min_x = min_x.min(label_left);
802        }
803    }
804
805    for c in &commits {
806        let r = if commit_symbol_type(&commits_by_id[&c.id]) == COMMIT_TYPE_MERGE {
807            9.0
808        } else {
809            10.0
810        };
811        min_x = min_x.min(c.x - r);
812        min_y = min_y.min(c.y - r);
813        max_x = max_x.max(c.x + r);
814        max_y = max_y.max(c.y + r);
815    }
816
817    let bounds = if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
818    {
819        Some(Bounds {
820            min_x: min_x - diagram_padding,
821            min_y: min_y - diagram_padding,
822            max_x: max_x + diagram_padding,
823            max_y: max_y + diagram_padding,
824        })
825    } else {
826        None
827    };
828
829    Ok(GitGraphDiagramLayout {
830        bounds,
831        direction,
832        rotate_commit_label,
833        show_branches,
834        show_commit_label,
835        parallel_commits,
836        diagram_padding,
837        max_pos,
838        branches,
839        commits,
840        arrows,
841    })
842}
843
844#[cfg(test)]
845mod tests {
846    #[test]
847    fn gitgraph_branch_label_bbox_width_overrides_are_generated() {
848        assert_eq!(
849            crate::generated::gitgraph_text_overrides_11_12_2::
850                lookup_gitgraph_branch_label_bbox_width_extra_px("develop"),
851            16.0 / 2048.0
852        );
853        assert_eq!(
854            crate::generated::gitgraph_text_overrides_11_12_2::
855                lookup_gitgraph_branch_label_bbox_width_extra_px("feature"),
856            -48.0 / 2048.0
857        );
858        assert_eq!(
859            crate::generated::gitgraph_text_overrides_11_12_2::
860                lookup_gitgraph_branch_label_bbox_width_extra_px("unknown"),
861            0.0
862        );
863        assert!(
864            crate::generated::gitgraph_text_overrides_11_12_2::
865                gitgraph_branch_label_bbox_corrections_enabled(
866                    "\"trebuchet ms\", verdana, arial, sans-serif;",
867                    16.0
868                )
869        );
870        assert_eq!(
871            crate::generated::gitgraph_text_overrides_11_12_2::
872                adjust_gitgraph_branch_label_bbox_width_px(56.0, "feature", true),
873            56.0 - 48.0 / 2048.0
874        );
875    }
876}