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