Skip to main content

merman_render/
c4.rs

1#![allow(clippy::too_many_arguments)]
2
3use crate::json::from_value_ref;
4use crate::model::{
5    Bounds, C4BoundaryLayout, C4DiagramLayout, C4ImageLayout, C4RelLayout, C4ShapeLayout,
6    C4TextBlockLayout, LayoutPoint,
7};
8use crate::text::{TextMeasurer, TextStyle, WrapMode};
9use crate::{Error, Result};
10use merman_core::MAX_DIAGRAM_NESTING_DEPTH;
11use serde::Deserialize;
12use serde_json::Value;
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, Deserialize)]
16#[serde(untagged)]
17enum C4Text {
18    Wrapped { text: Value },
19    String(String),
20    Value(Value),
21}
22
23impl Default for C4Text {
24    fn default() -> Self {
25        Self::String(String::new())
26    }
27}
28
29impl C4Text {
30    fn as_str(&self) -> &str {
31        match self {
32            Self::Wrapped { text } => text.as_str().unwrap_or(""),
33            Self::String(s) => s.as_str(),
34            Self::Value(v) => v.as_str().unwrap_or(""),
35        }
36    }
37}
38
39#[derive(Debug, Clone, Default, Deserialize)]
40struct C4LayoutConfig {
41    #[serde(default, rename = "c4ShapeInRow")]
42    c4_shape_in_row: i64,
43    #[serde(default, rename = "c4BoundaryInRow")]
44    c4_boundary_in_row: i64,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48struct C4Shape {
49    alias: String,
50    #[serde(default, rename = "parentBoundary")]
51    parent_boundary: String,
52    #[serde(default, rename = "typeC4Shape")]
53    type_c4_shape: C4Text,
54    #[serde(default)]
55    label: C4Text,
56    #[serde(default)]
57    #[allow(dead_code)]
58    wrap: bool,
59    #[serde(default)]
60    #[allow(dead_code)]
61    sprite: Option<Value>,
62    #[serde(default, rename = "type")]
63    ty: Option<C4Text>,
64    #[serde(default)]
65    techn: Option<C4Text>,
66    #[serde(default)]
67    descr: Option<C4Text>,
68}
69
70#[derive(Debug, Clone, Deserialize)]
71struct C4Boundary {
72    alias: String,
73    #[serde(default, rename = "parentBoundary")]
74    parent_boundary: String,
75    #[serde(default)]
76    label: C4Text,
77    #[serde(default, rename = "type")]
78    ty: Option<C4Text>,
79    #[serde(default)]
80    descr: Option<C4Text>,
81    #[serde(default)]
82    #[allow(dead_code)]
83    wrap: Option<bool>,
84    #[serde(default)]
85    #[allow(dead_code)]
86    sprite: Option<Value>,
87}
88
89#[derive(Debug, Clone, Deserialize)]
90struct C4Rel {
91    #[serde(rename = "from")]
92    from_alias: String,
93    #[serde(rename = "to")]
94    to_alias: String,
95    #[serde(rename = "type")]
96    rel_type: String,
97    #[serde(default)]
98    label: C4Text,
99    #[serde(default)]
100    techn: Option<C4Text>,
101    #[serde(default)]
102    descr: Option<C4Text>,
103    #[serde(default)]
104    #[allow(dead_code)]
105    wrap: bool,
106    #[serde(default, rename = "offsetX")]
107    offset_x: Option<i64>,
108    #[serde(default, rename = "offsetY")]
109    offset_y: Option<i64>,
110}
111
112#[derive(Debug, Clone, Deserialize)]
113struct C4Model {
114    #[serde(default, rename = "c4Type")]
115    c4_type: String,
116    #[serde(default)]
117    title: Option<String>,
118    #[serde(default)]
119    wrap: bool,
120    #[serde(default)]
121    layout: C4LayoutConfig,
122    #[serde(default)]
123    shapes: Vec<C4Shape>,
124    #[serde(default)]
125    boundaries: Vec<C4Boundary>,
126    #[serde(default)]
127    rels: Vec<C4Rel>,
128}
129
130fn json_f64(v: &Value) -> Option<f64> {
131    v.as_f64()
132        .or_else(|| v.as_i64().map(|n| n as f64))
133        .or_else(|| v.as_u64().map(|n| n as f64))
134}
135
136fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
137    let mut cur = cfg;
138    for key in path {
139        cur = cur.get(*key)?;
140    }
141    json_f64(cur)
142}
143
144fn config_bool(cfg: &Value, path: &[&str]) -> Option<bool> {
145    let mut cur = cfg;
146    for key in path {
147        cur = cur.get(*key)?;
148    }
149    cur.as_bool()
150}
151
152fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
153    let mut cur = cfg;
154    for key in path {
155        cur = cur.get(*key)?;
156    }
157    cur.as_str().map(|s| s.to_string())
158}
159
160#[derive(Debug, Clone)]
161struct C4Conf {
162    diagram_margin_x: f64,
163    diagram_margin_y: f64,
164    c4_shape_margin: f64,
165    c4_shape_padding: f64,
166    width: f64,
167    height: f64,
168    wrap: bool,
169    next_line_padding_x: f64,
170    boundary_font_family: Option<String>,
171    boundary_font_size: f64,
172    boundary_font_weight: Option<String>,
173    message_font_family: Option<String>,
174    message_font_size: f64,
175    message_font_weight: Option<String>,
176}
177
178impl C4Conf {
179    fn from_effective_config(effective_config: &Value) -> Self {
180        // Mermaid's C4 renderer (`packages/mermaid/src/diagrams/c4/c4Renderer.js`) calls
181        // `setConf(diagObj.db.getConfig())`, where `getConfig()` yields the diagram config object
182        // (i.e. `config.c4`), not the global config root. As a result, top-level `fontFamily`,
183        // `fontSize`, and `fontWeight` do not override C4-specific font defaults.
184        let message_font_family = config_string(effective_config, &["c4", "messageFontFamily"]);
185        let message_font_size =
186            config_f64(effective_config, &["c4", "messageFontSize"]).unwrap_or(12.0);
187        let message_font_weight = config_string(effective_config, &["c4", "messageFontWeight"]);
188
189        let boundary_font_family = config_string(effective_config, &["c4", "boundaryFontFamily"]);
190        let boundary_font_size =
191            config_f64(effective_config, &["c4", "boundaryFontSize"]).unwrap_or(14.0);
192        let boundary_font_weight = config_string(effective_config, &["c4", "boundaryFontWeight"]);
193
194        Self {
195            diagram_margin_x: config_f64(effective_config, &["c4", "diagramMarginX"])
196                .unwrap_or(50.0),
197            diagram_margin_y: config_f64(effective_config, &["c4", "diagramMarginY"])
198                .unwrap_or(10.0),
199            c4_shape_margin: config_f64(effective_config, &["c4", "c4ShapeMargin"]).unwrap_or(50.0),
200            c4_shape_padding: config_f64(effective_config, &["c4", "c4ShapePadding"])
201                .unwrap_or(20.0),
202            width: config_f64(effective_config, &["c4", "width"]).unwrap_or(216.0),
203            height: config_f64(effective_config, &["c4", "height"]).unwrap_or(60.0),
204            wrap: config_bool(effective_config, &["c4", "wrap"]).unwrap_or(true),
205            next_line_padding_x: config_f64(effective_config, &["c4", "nextLinePaddingX"])
206                .unwrap_or(0.0),
207            boundary_font_family,
208            boundary_font_size,
209            boundary_font_weight,
210            message_font_family,
211            message_font_size,
212            message_font_weight,
213        }
214    }
215
216    fn boundary_font(&self) -> TextStyle {
217        TextStyle {
218            font_family: self.boundary_font_family.clone(),
219            font_size: self.boundary_font_size,
220            font_weight: self.boundary_font_weight.clone(),
221        }
222    }
223
224    fn message_font(&self) -> TextStyle {
225        TextStyle {
226            font_family: self.message_font_family.clone(),
227            font_size: self.message_font_size,
228            font_weight: self.message_font_weight.clone(),
229        }
230    }
231
232    fn c4_shape_font(&self, effective_config: &Value, type_c4_shape: &str) -> TextStyle {
233        let key_family = format!("{type_c4_shape}FontFamily");
234        let key_size = format!("{type_c4_shape}FontSize");
235        let key_weight = format!("{type_c4_shape}FontWeight");
236
237        let font_family = config_string(effective_config, &["c4", &key_family]);
238        let font_size = config_f64(effective_config, &["c4", &key_size]).unwrap_or(14.0);
239        let font_weight = config_string(effective_config, &["c4", &key_weight]);
240
241        TextStyle {
242            font_family,
243            font_size,
244            font_weight,
245        }
246    }
247}
248
249#[derive(Debug, Clone, Copy)]
250struct TextMeasure {
251    width: f64,
252    height: f64,
253    line_count: usize,
254}
255
256fn measure_c4_text(
257    measurer: &dyn TextMeasurer,
258    text: &str,
259    style: &TextStyle,
260    wrap: bool,
261    text_limit_width: f64,
262) -> TextMeasure {
263    // Mermaid's `calculateTextWidth/Height` (used by C4) draws SVG `<text>` nodes, calls
264    // `getBBox()`, and then applies `Math.round(...)` per line. To keep C4 layout + viewport
265    // parity with upstream SVG baselines, we mirror that integer rounding behavior here.
266    fn js_round_pos(v: f64) -> f64 {
267        if !(v.is_finite() && v >= 0.0) {
268            0.0
269        } else {
270            (v + 0.5).floor()
271        }
272    }
273
274    fn c4_svg_bbox_line_height_px(style: &TextStyle) -> f64 {
275        // C4 in Mermaid@11.12.2 uses `calculateTextDimensions(...).height`, which is measured via
276        // SVG `getBBox()` and rounded with `Math.round`. Upstream fixtures show stable, integer
277        // per-line heights for the default C4 fonts:
278        // - 12px -> 14px
279        // - 14px -> 16px
280        // - 16px -> 17px
281        //
282        // These do not match our generic deterministic SVG line-height approximation (`1.1em`),
283        // so we treat them as C4-specific constants to keep layout bounds and root viewBox parity.
284        let fs = js_round_pos(style.font_size.max(1.0)) as i64;
285        crate::generated::c4_text_overrides_11_12_2::lookup_c4_svg_bbox_line_height_px(fs)
286            .unwrap_or_else(|| js_round_pos(style.font_size.max(1.0) * 1.1))
287    }
288
289    if wrap {
290        let m = measurer.measure_wrapped(text, style, Some(text_limit_width), WrapMode::SvgLike);
291        return TextMeasure {
292            width: text_limit_width,
293            height: c4_svg_bbox_line_height_px(style) * m.line_count.max(1) as f64,
294            line_count: m.line_count,
295        };
296    }
297
298    let mut width: f64 = 0.0;
299    let lines = crate::text::DeterministicTextMeasurer::normalized_text_lines(text);
300    for line in &lines {
301        let m = measurer.measure(line, style);
302        width = width.max(js_round_pos(m.width));
303    }
304    let height = c4_svg_bbox_line_height_px(style) * lines.len().max(1) as f64;
305    TextMeasure {
306        width,
307        height,
308        line_count: lines.len().max(1),
309    }
310}
311
312#[derive(Debug, Clone, Default)]
313struct BoundsData {
314    startx: Option<f64>,
315    stopx: Option<f64>,
316    starty: Option<f64>,
317    stopy: Option<f64>,
318    width_limit: f64,
319}
320
321#[derive(Debug, Clone, Default)]
322struct BoundsNext {
323    startx: f64,
324    stopx: f64,
325    starty: f64,
326    stopy: f64,
327    cnt: usize,
328}
329
330#[derive(Debug, Clone, Default)]
331struct BoundsState {
332    data: BoundsData,
333    next: BoundsNext,
334}
335
336impl BoundsState {
337    fn set_data(&mut self, startx: f64, stopx: f64, starty: f64, stopy: f64) {
338        self.next.startx = startx;
339        self.data.startx = Some(startx);
340        self.next.stopx = stopx;
341        self.data.stopx = Some(stopx);
342        self.next.starty = starty;
343        self.data.starty = Some(starty);
344        self.next.stopy = stopy;
345        self.data.stopy = Some(stopy);
346    }
347
348    fn bump_last_margin(&mut self, margin: f64) {
349        if let Some(v) = self.data.stopx.as_mut() {
350            *v += margin;
351        }
352        if let Some(v) = self.data.stopy.as_mut() {
353            *v += margin;
354        }
355    }
356
357    fn update_val_opt(target: &mut Option<f64>, val: f64, fun: fn(f64, f64) -> f64) {
358        match target {
359            None => *target = Some(val),
360            Some(existing) => *existing = fun(val, *existing),
361        }
362    }
363
364    fn update_val(target: &mut f64, val: f64, fun: fn(f64, f64) -> f64) {
365        *target = fun(val, *target);
366    }
367
368    fn insert_rect(&mut self, rect: &mut Rect, c4_shape_in_row: usize, conf: &C4Conf) {
369        self.next.cnt += 1;
370
371        let startx = if self.next.startx == self.next.stopx {
372            self.next.stopx + rect.margin
373        } else {
374            self.next.stopx + rect.margin * 2.0
375        };
376        let mut stopx = startx + rect.size.width;
377        let starty = self.next.starty + rect.margin * 2.0;
378        let mut stopy = starty + rect.size.height;
379
380        if startx >= self.data.width_limit
381            || stopx >= self.data.width_limit
382            || self.next.cnt > c4_shape_in_row
383        {
384            let startx2 = self.next.startx + rect.margin + conf.next_line_padding_x;
385            let starty2 = self.next.stopy + rect.margin * 2.0;
386
387            stopx = startx2 + rect.size.width;
388            stopy = starty2 + rect.size.height;
389
390            self.next.stopx = stopx;
391            self.next.starty = self.next.stopy;
392            self.next.stopy = stopy;
393            self.next.cnt = 1;
394
395            rect.origin.x = startx2;
396            rect.origin.y = starty2;
397        } else {
398            rect.origin.x = startx;
399            rect.origin.y = starty;
400        }
401
402        Self::update_val_opt(&mut self.data.startx, rect.origin.x, f64::min);
403        Self::update_val_opt(&mut self.data.starty, rect.origin.y, f64::min);
404        Self::update_val_opt(&mut self.data.stopx, stopx, f64::max);
405        Self::update_val_opt(&mut self.data.stopy, stopy, f64::max);
406
407        Self::update_val(&mut self.next.startx, rect.origin.x, f64::min);
408        Self::update_val(&mut self.next.starty, rect.origin.y, f64::min);
409        Self::update_val(&mut self.next.stopx, stopx, f64::max);
410        Self::update_val(&mut self.next.stopy, stopy, f64::max);
411    }
412}
413
414#[derive(Debug, Clone)]
415struct Rect {
416    origin: merman_core::geom::Point,
417    size: merman_core::geom::Size,
418    margin: f64,
419}
420
421fn has_sprite(v: &Option<Value>) -> bool {
422    v.as_ref().is_some_and(|v| match v {
423        Value::Null => false,
424        Value::Bool(b) => *b,
425        Value::Number(_) => true,
426        Value::String(s) => !s.trim().is_empty(),
427        Value::Array(a) => !a.is_empty(),
428        Value::Object(o) => !o.is_empty(),
429    })
430}
431
432fn intersect_point(from: &Rect, end_point: LayoutPoint) -> LayoutPoint {
433    let x1 = from.origin.x;
434    let y1 = from.origin.y;
435    let x2 = end_point.x;
436    let y2 = end_point.y;
437
438    let from_center_x = x1 + from.size.width / 2.0;
439    let from_center_y = y1 + from.size.height / 2.0;
440
441    let dx = (x1 - x2).abs();
442    let dy = (y1 - y2).abs();
443    let tan_dyx = dy / dx;
444    let from_dyx = from.size.height / from.size.width;
445
446    let mut return_point: Option<LayoutPoint> = None;
447
448    if y1 == y2 && x1 < x2 {
449        return_point = Some(LayoutPoint {
450            x: x1 + from.size.width,
451            y: from_center_y,
452        });
453    } else if y1 == y2 && x1 > x2 {
454        return_point = Some(LayoutPoint {
455            x: x1,
456            y: from_center_y,
457        });
458    } else if x1 == x2 && y1 < y2 {
459        return_point = Some(LayoutPoint {
460            x: from_center_x,
461            y: y1 + from.size.height,
462        });
463    } else if x1 == x2 && y1 > y2 {
464        return_point = Some(LayoutPoint {
465            x: from_center_x,
466            y: y1,
467        });
468    }
469
470    if x1 > x2 && y1 < y2 {
471        if from_dyx >= tan_dyx {
472            return_point = Some(LayoutPoint {
473                x: x1,
474                y: from_center_y + (tan_dyx * from.size.width) / 2.0,
475            });
476        } else {
477            return_point = Some(LayoutPoint {
478                x: from_center_x - ((dx / dy) * from.size.height) / 2.0,
479                y: y1 + from.size.height,
480            });
481        }
482    } else if x1 < x2 && y1 < y2 {
483        if from_dyx >= tan_dyx {
484            return_point = Some(LayoutPoint {
485                x: x1 + from.size.width,
486                y: from_center_y + (tan_dyx * from.size.width) / 2.0,
487            });
488        } else {
489            return_point = Some(LayoutPoint {
490                x: from_center_x + ((dx / dy) * from.size.height) / 2.0,
491                y: y1 + from.size.height,
492            });
493        }
494    } else if x1 < x2 && y1 > y2 {
495        if from_dyx >= tan_dyx {
496            return_point = Some(LayoutPoint {
497                x: x1 + from.size.width,
498                y: from_center_y - (tan_dyx * from.size.width) / 2.0,
499            });
500        } else {
501            return_point = Some(LayoutPoint {
502                x: from_center_x + ((from.size.height / 2.0) * dx) / dy,
503                y: y1,
504            });
505        }
506    } else if x1 > x2 && y1 > y2 {
507        if from_dyx >= tan_dyx {
508            return_point = Some(LayoutPoint {
509                x: x1,
510                y: from_center_y - (from.size.width / 2.0) * tan_dyx,
511            });
512        } else {
513            return_point = Some(LayoutPoint {
514                x: from_center_x - ((from.size.height / 2.0) * dx) / dy,
515                y: y1,
516            });
517        }
518    }
519
520    return_point.unwrap_or(LayoutPoint {
521        x: from_center_x,
522        y: from_center_y,
523    })
524}
525
526fn intersect_points(from: &Rect, to: &Rect) -> (LayoutPoint, LayoutPoint) {
527    let end_intersect_point = LayoutPoint {
528        x: to.origin.x + to.size.width / 2.0,
529        y: to.origin.y + to.size.height / 2.0,
530    };
531    let start_point = intersect_point(from, end_intersect_point);
532
533    let end_intersect_point = LayoutPoint {
534        x: from.origin.x + from.size.width / 2.0,
535        y: from.origin.y + from.size.height / 2.0,
536    };
537    let end_point = intersect_point(to, end_intersect_point);
538
539    (start_point, end_point)
540}
541
542fn layout_c4_shape_array(
543    current_bounds: &mut BoundsState,
544    shape_indices: &[usize],
545    model: &C4Model,
546    effective_config: &Value,
547    conf: &C4Conf,
548    c4_shape_in_row: usize,
549    measurer: &dyn TextMeasurer,
550    out_shapes: &mut HashMap<String, C4ShapeLayout>,
551) {
552    for idx in shape_indices {
553        let shape = &model.shapes[*idx];
554        let mut y = conf.c4_shape_padding;
555
556        let type_c4_shape = shape.type_c4_shape.as_str().to_string();
557        let mut type_conf = conf.c4_shape_font(effective_config, &type_c4_shape);
558        type_conf.font_size -= 2.0;
559
560        let type_text = format!("«{}»", type_c4_shape);
561        let type_metrics = measurer.measure(&type_text, &type_conf);
562        let type_block = C4TextBlockLayout {
563            text: type_text,
564            y,
565            width: type_metrics.width,
566            height: type_conf.font_size + 2.0,
567            line_count: 1,
568        };
569        y = y + type_block.height - 4.0;
570
571        let mut image = C4ImageLayout {
572            width: 0.0,
573            height: 0.0,
574            y: 0.0,
575        };
576        if matches!(type_c4_shape.as_str(), "person" | "external_person") {
577            image.width = 48.0;
578            image.height = 48.0;
579            image.y = y;
580            y = image.y + image.height;
581        }
582        if has_sprite(&shape.sprite) {
583            image.width = 48.0;
584            image.height = 48.0;
585            image.y = y;
586            y = image.y + image.height;
587        }
588
589        let text_wrap = shape.wrap && conf.wrap;
590        let text_limit_width = conf.width - conf.c4_shape_padding * 2.0;
591
592        let mut label_conf = conf.c4_shape_font(effective_config, &type_c4_shape);
593        label_conf.font_size += 2.0;
594        label_conf.font_weight = Some("bold".to_string());
595
596        let label_text = shape.label.as_str().to_string();
597        let label_m = measure_c4_text(
598            measurer,
599            &label_text,
600            &label_conf,
601            text_wrap,
602            text_limit_width,
603        );
604        let label = C4TextBlockLayout {
605            text: label_text,
606            y: y + 8.0,
607            width: label_m.width,
608            height: label_m.height,
609            line_count: label_m.line_count,
610        };
611        y = label.y + label.height;
612
613        let mut ty_block: Option<C4TextBlockLayout> = None;
614        let mut techn_block: Option<C4TextBlockLayout> = None;
615
616        if let Some(ty) = shape.ty.as_ref().filter(|t| !t.as_str().is_empty()) {
617            let type_text = format!("[{}]", ty.as_str());
618            let type_conf = conf.c4_shape_font(effective_config, &type_c4_shape);
619            let m = measure_c4_text(
620                measurer,
621                &type_text,
622                &type_conf,
623                text_wrap,
624                text_limit_width,
625            );
626            let block = C4TextBlockLayout {
627                text: type_text,
628                y: y + 5.0,
629                width: m.width,
630                height: m.height,
631                line_count: m.line_count,
632            };
633            y = block.y + block.height;
634            ty_block = Some(block);
635        } else if let Some(techn) = shape.techn.as_ref().filter(|t| !t.as_str().is_empty()) {
636            let techn_text = format!("[{}]", techn.as_str());
637            // Mermaid@11.12.2 C4 renderer quirk: `techn` text is measured with
638            // `c4ShapeFont(conf, c4Shape.techn.text)`, where `c4Shape.techn.text` already contains
639            // the bracketed string (e.g. `[Rust]`). That key does not exist in the config object,
640            // so the downstream `calculateTextDimensions` falls back to its defaults
641            // (`fontSize=12`, `fontFamily='Arial'`).
642            //
643            // Upstream SVG baselines encode this behavior into shape heights and ultimately the
644            // root viewBox. Mirror it here for parity.
645            let techn_conf = TextStyle {
646                font_family: Some("Arial".to_string()),
647                font_size: 12.0,
648                font_weight: None,
649            };
650            let m = measure_c4_text(
651                measurer,
652                &techn_text,
653                &techn_conf,
654                text_wrap,
655                text_limit_width,
656            );
657            let block = C4TextBlockLayout {
658                text: techn_text,
659                y: y + 5.0,
660                width: m.width,
661                height: m.height,
662                line_count: m.line_count,
663            };
664            y = block.y + block.height;
665            techn_block = Some(block);
666        }
667
668        let mut rect_height = y;
669        let mut rect_width = label.width;
670
671        let mut descr_block: Option<C4TextBlockLayout> = None;
672        if let Some(descr) = shape.descr.as_ref().filter(|t| !t.as_str().is_empty()) {
673            let descr_text = descr.as_str().to_string();
674            let descr_conf = conf.c4_shape_font(effective_config, &type_c4_shape);
675            let m = measure_c4_text(
676                measurer,
677                &descr_text,
678                &descr_conf,
679                text_wrap,
680                text_limit_width,
681            );
682            let block = C4TextBlockLayout {
683                text: descr_text,
684                y: y + 20.0,
685                width: m.width,
686                height: m.height,
687                line_count: m.line_count,
688            };
689            y = block.y + block.height;
690            rect_width = rect_width.max(block.width);
691            rect_height = y - block.line_count as f64 * 5.0;
692            descr_block = Some(block);
693        }
694
695        rect_width += conf.c4_shape_padding;
696
697        let width = conf.width.max(rect_width);
698        let height = conf.height.max(rect_height);
699        let margin = conf.c4_shape_margin;
700
701        let mut rect = Rect {
702            origin: merman_core::geom::point(0.0, 0.0),
703            size: merman_core::geom::Size::new(width, height),
704            margin,
705        };
706        current_bounds.insert_rect(&mut rect, c4_shape_in_row, conf);
707
708        out_shapes.insert(
709            shape.alias.clone(),
710            C4ShapeLayout {
711                alias: shape.alias.clone(),
712                parent_boundary: shape.parent_boundary.clone(),
713                type_c4_shape: type_c4_shape.clone(),
714                x: rect.origin.x,
715                y: rect.origin.y,
716                width: rect.size.width,
717                height: rect.size.height,
718                margin: rect.margin,
719                image,
720                type_block,
721                label,
722                ty: ty_block,
723                techn: techn_block,
724                descr: descr_block,
725            },
726        );
727    }
728
729    current_bounds.bump_last_margin(conf.c4_shape_margin);
730}
731
732fn layout_inside_boundary(
733    parent_bounds: &mut BoundsState,
734    boundary_indices: &[usize],
735    model: &C4Model,
736    effective_config: &Value,
737    conf: &C4Conf,
738    c4_shape_in_row: usize,
739    c4_boundary_in_row: usize,
740    measurer: &dyn TextMeasurer,
741    boundary_children: &HashMap<String, Vec<usize>>,
742    shape_children: &HashMap<String, Vec<usize>>,
743    out_boundaries: &mut HashMap<String, C4BoundaryLayout>,
744    out_shapes: &mut HashMap<String, C4ShapeLayout>,
745    global_max_x: &mut f64,
746    global_max_y: &mut f64,
747) -> Result<()> {
748    let mut current_bounds = BoundsState::default();
749
750    let denom = c4_boundary_in_row.min(boundary_indices.len().max(1));
751    let width_limit = parent_bounds.data.width_limit / denom as f64;
752    current_bounds.data.width_limit = width_limit;
753
754    for (i, idx) in boundary_indices.iter().enumerate() {
755        let boundary = &model.boundaries[*idx];
756        let mut y = 0.0;
757
758        let mut image = C4ImageLayout {
759            width: 0.0,
760            height: 0.0,
761            y: 0.0,
762        };
763        if has_sprite(&boundary.sprite) {
764            image.width = 48.0;
765            image.height = 48.0;
766            image.y = y;
767            y = image.y + image.height;
768        }
769
770        let text_wrap = boundary.wrap.unwrap_or(model.wrap) && conf.wrap;
771        let mut label_conf = conf.boundary_font();
772        label_conf.font_size += 2.0;
773        label_conf.font_weight = Some("bold".to_string());
774
775        let label_text = boundary.label.as_str().to_string();
776        let label_m = measure_c4_text(measurer, &label_text, &label_conf, text_wrap, width_limit);
777        let label = C4TextBlockLayout {
778            text: label_text,
779            y: y + 8.0,
780            width: label_m.width,
781            height: label_m.height,
782            line_count: label_m.line_count,
783        };
784        y = label.y + label.height;
785
786        let mut ty_block: Option<C4TextBlockLayout> = None;
787        if let Some(ty) = boundary.ty.as_ref().filter(|t| !t.as_str().is_empty()) {
788            let ty_text = format!("[{}]", ty.as_str());
789            let ty_conf = conf.boundary_font();
790            let m = measure_c4_text(measurer, &ty_text, &ty_conf, text_wrap, width_limit);
791            let block = C4TextBlockLayout {
792                text: ty_text,
793                y: y + 5.0,
794                width: m.width,
795                height: m.height,
796                line_count: m.line_count,
797            };
798            y = block.y + block.height;
799            ty_block = Some(block);
800        }
801
802        let mut descr_block: Option<C4TextBlockLayout> = None;
803        if let Some(descr) = boundary.descr.as_ref().filter(|t| !t.as_str().is_empty()) {
804            let descr_text = descr.as_str().to_string();
805            let mut descr_conf = conf.boundary_font();
806            descr_conf.font_size -= 2.0;
807            let m = measure_c4_text(measurer, &descr_text, &descr_conf, text_wrap, width_limit);
808            let block = C4TextBlockLayout {
809                text: descr_text,
810                y: y + 20.0,
811                width: m.width,
812                height: m.height,
813                line_count: m.line_count,
814            };
815            y = block.y + block.height;
816            descr_block = Some(block);
817        }
818
819        let parent_startx = parent_bounds
820            .data
821            .startx
822            .ok_or_else(|| Error::InvalidModel {
823                message: "c4: parent bounds missing startx".to_string(),
824            })?;
825        let parent_stopy = parent_bounds
826            .data
827            .stopy
828            .ok_or_else(|| Error::InvalidModel {
829                message: "c4: parent bounds missing stopy".to_string(),
830            })?;
831
832        if i == 0 || i % c4_boundary_in_row == 0 {
833            let x = parent_startx + conf.diagram_margin_x;
834            let y0 = parent_stopy + conf.diagram_margin_y + y;
835            current_bounds.set_data(x, x, y0, y0);
836        } else {
837            let startx = current_bounds.data.startx.unwrap_or(parent_startx);
838            let stopx = current_bounds.data.stopx.unwrap_or(startx);
839            let x = if stopx != startx {
840                stopx + conf.diagram_margin_x
841            } else {
842                startx
843            };
844            let y0 = current_bounds.data.starty.unwrap_or(parent_stopy);
845            current_bounds.set_data(x, x, y0, y0);
846        }
847
848        if let Some(shape_indices) = shape_children.get(&boundary.alias) {
849            if !shape_indices.is_empty() {
850                layout_c4_shape_array(
851                    &mut current_bounds,
852                    shape_indices,
853                    model,
854                    effective_config,
855                    conf,
856                    c4_shape_in_row,
857                    measurer,
858                    out_shapes,
859                );
860            }
861        }
862
863        if let Some(next_boundaries) = boundary_children.get(&boundary.alias) {
864            if !next_boundaries.is_empty() {
865                layout_inside_boundary(
866                    &mut current_bounds,
867                    next_boundaries,
868                    model,
869                    effective_config,
870                    conf,
871                    c4_shape_in_row,
872                    c4_boundary_in_row,
873                    measurer,
874                    boundary_children,
875                    shape_children,
876                    out_boundaries,
877                    out_shapes,
878                    global_max_x,
879                    global_max_y,
880                )?;
881            }
882        }
883
884        let startx = current_bounds.data.startx.unwrap_or(0.0);
885        let stopx = current_bounds.data.stopx.unwrap_or(startx);
886        let starty = current_bounds.data.starty.unwrap_or(0.0);
887        let stopy = current_bounds.data.stopy.unwrap_or(starty);
888
889        out_boundaries.insert(
890            boundary.alias.clone(),
891            C4BoundaryLayout {
892                alias: boundary.alias.clone(),
893                parent_boundary: boundary.parent_boundary.clone(),
894                x: startx,
895                y: starty,
896                width: stopx - startx,
897                height: stopy - starty,
898                image,
899                label,
900                ty: ty_block,
901                descr: descr_block,
902            },
903        );
904
905        let stopx_with_margin = stopx + conf.c4_shape_margin;
906        let stopy_with_margin = stopy + conf.c4_shape_margin;
907        parent_bounds.data.stopx = Some(
908            parent_bounds
909                .data
910                .stopx
911                .unwrap_or(stopx_with_margin)
912                .max(stopx_with_margin),
913        );
914        parent_bounds.data.stopy = Some(
915            parent_bounds
916                .data
917                .stopy
918                .unwrap_or(stopy_with_margin)
919                .max(stopy_with_margin),
920        );
921
922        *global_max_x = global_max_x.max(parent_bounds.data.stopx.unwrap_or(*global_max_x));
923        *global_max_y = global_max_y.max(parent_bounds.data.stopy.unwrap_or(*global_max_y));
924    }
925
926    Ok(())
927}
928
929pub(crate) fn layout_c4_diagram(
930    model: &Value,
931    effective_config: &Value,
932    measurer: &dyn TextMeasurer,
933    viewport_width: f64,
934    viewport_height: f64,
935) -> Result<C4DiagramLayout> {
936    let model: C4Model = from_value_ref(model)?;
937    let conf = C4Conf::from_effective_config(effective_config);
938
939    let c4_shape_in_row = (model.layout.c4_shape_in_row.max(1)) as usize;
940    let c4_boundary_in_row = (model.layout.c4_boundary_in_row.max(1)) as usize;
941
942    let mut boundary_children: HashMap<String, Vec<usize>> = HashMap::new();
943    for (i, b) in model.boundaries.iter().enumerate() {
944        boundary_children
945            .entry(b.parent_boundary.clone())
946            .or_default()
947            .push(i);
948    }
949    validate_c4_boundary_depth(&model, &boundary_children)?;
950    let mut shape_children: HashMap<String, Vec<usize>> = HashMap::new();
951    for (i, s) in model.shapes.iter().enumerate() {
952        shape_children
953            .entry(s.parent_boundary.clone())
954            .or_default()
955            .push(i);
956    }
957
958    let mut out_boundaries: HashMap<String, C4BoundaryLayout> = HashMap::new();
959    let mut out_shapes: HashMap<String, C4ShapeLayout> = HashMap::new();
960
961    let mut screen_bounds = BoundsState::default();
962    screen_bounds.set_data(
963        conf.diagram_margin_x,
964        conf.diagram_margin_x,
965        conf.diagram_margin_y,
966        conf.diagram_margin_y,
967    );
968    screen_bounds.data.width_limit = viewport_width;
969
970    let mut global_max_x = conf.diagram_margin_x;
971    let mut global_max_y = conf.diagram_margin_y;
972
973    let root_boundaries = boundary_children.get("").cloned().unwrap_or_default();
974    if root_boundaries.is_empty() {
975        return Err(Error::InvalidModel {
976            message: "c4: expected at least the implicit global boundary".to_string(),
977        });
978    }
979
980    layout_inside_boundary(
981        &mut screen_bounds,
982        &root_boundaries,
983        &model,
984        effective_config,
985        &conf,
986        c4_shape_in_row,
987        c4_boundary_in_row,
988        measurer,
989        &boundary_children,
990        &shape_children,
991        &mut out_boundaries,
992        &mut out_shapes,
993        &mut global_max_x,
994        &mut global_max_y,
995    )?;
996
997    screen_bounds.data.stopx = Some(global_max_x);
998    screen_bounds.data.stopy = Some(global_max_y);
999
1000    let box_startx = screen_bounds.data.startx.unwrap_or(0.0);
1001    let box_starty = screen_bounds.data.starty.unwrap_or(0.0);
1002    let box_stopx = screen_bounds.data.stopx.unwrap_or(conf.diagram_margin_x);
1003    let box_stopy = screen_bounds.data.stopy.unwrap_or(conf.diagram_margin_y);
1004
1005    let width = (box_stopx - box_startx) + 2.0 * conf.diagram_margin_x;
1006    let height = (box_stopy - box_starty) + 2.0 * conf.diagram_margin_y;
1007
1008    let bounds = Some(Bounds {
1009        min_x: box_startx,
1010        min_y: box_starty,
1011        max_x: box_stopx,
1012        max_y: box_stopy,
1013    });
1014
1015    let mut shape_rects: HashMap<&str, Rect> = HashMap::new();
1016    for s in model.shapes.iter() {
1017        let Some(l) = out_shapes.get(&s.alias) else {
1018            continue;
1019        };
1020        shape_rects.insert(
1021            s.alias.as_str(),
1022            Rect {
1023                origin: merman_core::geom::point(l.x, l.y),
1024                size: merman_core::geom::Size::new(l.width, l.height),
1025                margin: l.margin,
1026            },
1027        );
1028    }
1029
1030    let rel_font = conf.message_font();
1031    let mut rels_out: Vec<C4RelLayout> = Vec::new();
1032    for (i, rel) in model.rels.iter().enumerate() {
1033        let mut label_text = rel.label.as_str().to_string();
1034        if model.c4_type == "C4Dynamic" {
1035            label_text = format!("{}: {}", i + 1, label_text);
1036        }
1037
1038        let rel_text_wrap = rel.wrap && conf.wrap;
1039
1040        let label_limit = measurer.measure(&label_text, &rel_font).width;
1041        let label_m = measure_c4_text(measurer, &label_text, &rel_font, rel_text_wrap, label_limit);
1042        let label = C4TextBlockLayout {
1043            text: label_text,
1044            y: 0.0,
1045            width: label_m.width,
1046            height: label_m.height,
1047            line_count: label_m.line_count,
1048        };
1049
1050        let techn = rel
1051            .techn
1052            .as_ref()
1053            .filter(|t| !t.as_str().is_empty())
1054            .map(|t| {
1055                let text = t.as_str().to_string();
1056                let limit = measurer.measure(&text, &rel_font).width;
1057                let m = measure_c4_text(measurer, &text, &rel_font, rel_text_wrap, limit);
1058                C4TextBlockLayout {
1059                    text,
1060                    y: 0.0,
1061                    width: m.width,
1062                    height: m.height,
1063                    line_count: m.line_count,
1064                }
1065            });
1066
1067        let descr = rel
1068            .descr
1069            .as_ref()
1070            .filter(|t| !t.as_str().is_empty())
1071            .map(|t| {
1072                let text = t.as_str().to_string();
1073                let limit = measurer.measure(&text, &rel_font).width;
1074                let m = measure_c4_text(measurer, &text, &rel_font, rel_text_wrap, limit);
1075                C4TextBlockLayout {
1076                    text,
1077                    y: 0.0,
1078                    width: m.width,
1079                    height: m.height,
1080                    line_count: m.line_count,
1081                }
1082            });
1083
1084        let from = shape_rects
1085            .get(rel.from_alias.as_str())
1086            .ok_or_else(|| Error::InvalidModel {
1087                message: format!(
1088                    "c4: relationship references missing from shape {}",
1089                    rel.from_alias
1090                ),
1091            })?;
1092        let to = shape_rects
1093            .get(rel.to_alias.as_str())
1094            .ok_or_else(|| Error::InvalidModel {
1095                message: format!(
1096                    "c4: relationship references missing to shape {}",
1097                    rel.to_alias
1098                ),
1099            })?;
1100
1101        let (start_point, end_point) = intersect_points(from, to);
1102
1103        rels_out.push(C4RelLayout {
1104            from: rel.from_alias.clone(),
1105            to: rel.to_alias.clone(),
1106            rel_type: rel.rel_type.clone(),
1107            start_point,
1108            end_point,
1109            offset_x: rel.offset_x,
1110            offset_y: rel.offset_y,
1111            label,
1112            techn,
1113            descr,
1114        });
1115    }
1116
1117    let mut boundaries_out = Vec::with_capacity(model.boundaries.len());
1118    for b in &model.boundaries {
1119        let Some(l) = out_boundaries.get(&b.alias) else {
1120            return Err(Error::InvalidModel {
1121                message: format!("c4: missing boundary layout for {}", b.alias),
1122            });
1123        };
1124        boundaries_out.push(l.clone());
1125    }
1126
1127    let mut shapes_out = Vec::with_capacity(model.shapes.len());
1128    for s in &model.shapes {
1129        let Some(l) = out_shapes.get(&s.alias) else {
1130            return Err(Error::InvalidModel {
1131                message: format!("c4: missing shape layout for {}", s.alias),
1132            });
1133        };
1134        shapes_out.push(l.clone());
1135    }
1136
1137    Ok(C4DiagramLayout {
1138        bounds,
1139        width,
1140        height,
1141        viewport_width,
1142        viewport_height,
1143        c4_type: model.c4_type,
1144        title: model.title,
1145        boundaries: boundaries_out,
1146        shapes: shapes_out,
1147        rels: rels_out,
1148    })
1149}
1150
1151fn validate_c4_boundary_depth(
1152    model: &C4Model,
1153    boundary_children: &HashMap<String, Vec<usize>>,
1154) -> Result<()> {
1155    let roots = boundary_children.get("").cloned().unwrap_or_default();
1156    let mut stack: Vec<(usize, usize)> = roots.into_iter().map(|idx| (idx, 1)).collect();
1157    while let Some((idx, depth)) = stack.pop() {
1158        if depth > MAX_DIAGRAM_NESTING_DEPTH {
1159            return Err(Error::InvalidModel {
1160                message: format!(
1161                    "c4 boundary nesting depth exceeds maximum of {MAX_DIAGRAM_NESTING_DEPTH}"
1162                ),
1163            });
1164        }
1165        let Some(boundary) = model.boundaries.get(idx) else {
1166            continue;
1167        };
1168        if let Some(children) = boundary_children.get(&boundary.alias) {
1169            for &child in children {
1170                stack.push((child, depth + 1));
1171            }
1172        }
1173    }
1174    Ok(())
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    #[test]
1180    fn c4_svg_bbox_line_height_overrides_are_generated() {
1181        assert_eq!(
1182            crate::generated::c4_text_overrides_11_12_2::lookup_c4_svg_bbox_line_height_px(12),
1183            Some(14.0)
1184        );
1185        assert_eq!(
1186            crate::generated::c4_text_overrides_11_12_2::lookup_c4_svg_bbox_line_height_px(14),
1187            Some(16.0)
1188        );
1189        assert_eq!(
1190            crate::generated::c4_text_overrides_11_12_2::lookup_c4_svg_bbox_line_height_px(16),
1191            Some(17.0)
1192        );
1193        assert_eq!(
1194            crate::generated::c4_text_overrides_11_12_2::lookup_c4_svg_bbox_line_height_px(15),
1195            None
1196        );
1197    }
1198}