Skip to main content

merman_render/
sequence.rs

1#![allow(clippy::too_many_arguments)]
2
3use crate::generated::sequence_text_overrides_11_12_2 as sequence_text_overrides;
4use crate::model::{
5    Bounds, LayoutCluster, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint, SequenceDiagramLayout,
6};
7use crate::text::{
8    TextMeasurer, TextStyle, WrapMode, split_html_br_lines, wrap_label_like_mermaid_lines,
9    wrap_label_like_mermaid_lines_floored_bbox,
10};
11use crate::{Error, Result};
12use serde::Deserialize;
13use serde_json::Value;
14
15#[derive(Debug, Clone, Deserialize)]
16struct SequenceActor {
17    #[allow(dead_code)]
18    name: String,
19    description: String,
20    #[serde(rename = "type")]
21    actor_type: String,
22    #[allow(dead_code)]
23    wrap: bool,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27struct SequenceMessage {
28    id: String,
29    #[serde(default)]
30    from: Option<String>,
31    #[serde(default)]
32    to: Option<String>,
33    #[serde(rename = "type")]
34    message_type: i32,
35    message: Value,
36    #[allow(dead_code)]
37    wrap: bool,
38    activate: bool,
39    #[serde(default)]
40    placement: Option<i32>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44struct SequenceBox {
45    #[serde(rename = "actorKeys")]
46    actor_keys: Vec<String>,
47    #[allow(dead_code)]
48    fill: String,
49    name: Option<String>,
50    #[allow(dead_code)]
51    wrap: bool,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55struct SequenceModel {
56    #[serde(rename = "actorOrder")]
57    actor_order: Vec<String>,
58    actors: std::collections::BTreeMap<String, SequenceActor>,
59    #[serde(default)]
60    boxes: Vec<SequenceBox>,
61    messages: Vec<SequenceMessage>,
62    title: Option<String>,
63    #[serde(rename = "createdActors", default)]
64    created_actors: std::collections::BTreeMap<String, usize>,
65    #[serde(rename = "destroyedActors", default)]
66    destroyed_actors: std::collections::BTreeMap<String, usize>,
67}
68
69fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
70    let mut cur = cfg;
71    for key in path {
72        cur = cur.get(*key)?;
73    }
74    cur.as_f64()
75        .or_else(|| cur.as_i64().map(|n| n as f64))
76        .or_else(|| cur.as_u64().map(|n| n as f64))
77}
78
79fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
80    let mut cur = cfg;
81    for key in path {
82        cur = cur.get(*key)?;
83    }
84    cur.as_str().map(|s| s.to_string())
85}
86
87fn measure_svg_like_with_html_br(
88    measurer: &dyn TextMeasurer,
89    text: &str,
90    style: &TextStyle,
91) -> (f64, f64) {
92    let lines = split_html_br_lines(text);
93    let default_line_height = (style.font_size.max(1.0) * 1.1).max(1.0);
94    if lines.len() <= 1 {
95        let metrics = measurer.measure_wrapped(text, style, None, WrapMode::SvgLikeSingleRun);
96        let h = if metrics.height > 0.0 {
97            metrics.height
98        } else {
99            default_line_height
100        };
101        return (metrics.width.max(0.0), h.max(0.0));
102    }
103    let mut max_w: f64 = 0.0;
104    let mut line_h: f64 = 0.0;
105    for line in &lines {
106        let metrics = measurer.measure_wrapped(line, style, None, WrapMode::SvgLikeSingleRun);
107        max_w = max_w.max(metrics.width.max(0.0));
108        let h = if metrics.height > 0.0 {
109            metrics.height
110        } else {
111            default_line_height
112        };
113        line_h = line_h.max(h.max(0.0));
114    }
115    (
116        max_w,
117        (line_h * lines.len() as f64).max(default_line_height),
118    )
119}
120
121fn sequence_actor_visual_height(
122    actor_type: &str,
123    base_width: f64,
124    base_height: f64,
125    label_box_height: f64,
126) -> f64 {
127    match actor_type {
128        // Mermaid (11.12.2) derives these from the actor-type glyph bbox + label box height.
129        // These heights are used by the footer actor rendering and affect the final SVG viewBox.
130        "boundary" => (60.0 + label_box_height).max(1.0),
131        // Mermaid's database actor updates the actor height after the top render.
132        // The footer render uses that updated height: ≈ width/4 + labelBoxHeight.
133        "database" => ((base_width / 4.0) + label_box_height).max(1.0),
134        "entity" => (36.0 + label_box_height).max(1.0),
135        // Control uses an extra label-box height in Mermaid.
136        "control" => (36.0 + 2.0 * label_box_height).max(1.0),
137        _ => base_height.max(1.0),
138    }
139}
140
141fn sequence_actor_lifeline_start_y(
142    actor_type: &str,
143    base_height: f64,
144    box_text_margin: f64,
145) -> f64 {
146    match actor_type {
147        // Hard-coded in Mermaid's sequence svgDraw.js for these actor types.
148        "actor" | "boundary" => 80.0,
149        "control" | "entity" => 75.0,
150        // For database, Mermaid starts the lifeline slightly below the actor box.
151        "database" => base_height + 2.0 * box_text_margin,
152        _ => base_height,
153    }
154}
155
156pub fn layout_sequence_diagram(
157    semantic: &Value,
158    effective_config: &Value,
159    measurer: &dyn TextMeasurer,
160) -> Result<SequenceDiagramLayout> {
161    let model: SequenceModel = crate::json::from_value_ref(semantic)?;
162
163    let seq_cfg = effective_config.get("sequence").unwrap_or(&Value::Null);
164    let diagram_margin_x = config_f64(seq_cfg, &["diagramMarginX"]).unwrap_or(50.0);
165    let diagram_margin_y = config_f64(seq_cfg, &["diagramMarginY"]).unwrap_or(10.0);
166    let bottom_margin_adj = config_f64(seq_cfg, &["bottomMarginAdj"]).unwrap_or(1.0);
167    let box_margin = config_f64(seq_cfg, &["boxMargin"]).unwrap_or(10.0);
168    let actor_margin = config_f64(seq_cfg, &["actorMargin"]).unwrap_or(50.0);
169    let actor_width_min = config_f64(seq_cfg, &["width"]).unwrap_or(150.0);
170    let actor_height = config_f64(seq_cfg, &["height"]).unwrap_or(65.0);
171    let message_margin = config_f64(seq_cfg, &["messageMargin"]).unwrap_or(35.0);
172    let wrap_padding = config_f64(seq_cfg, &["wrapPadding"]).unwrap_or(10.0);
173    let box_text_margin = config_f64(seq_cfg, &["boxTextMargin"]).unwrap_or(5.0);
174    let label_box_height = config_f64(seq_cfg, &["labelBoxHeight"]).unwrap_or(20.0);
175    let mirror_actors = seq_cfg
176        .get("mirrorActors")
177        .and_then(|v| v.as_bool())
178        .unwrap_or(true);
179
180    // Mermaid's `sequenceRenderer.setConf(...)` overrides per-sequence font settings whenever the
181    // global `fontFamily` / `fontSize` / `fontWeight` are present (defaults are always present).
182    let global_font_family = config_string(effective_config, &["fontFamily"]);
183    let global_font_size = config_f64(effective_config, &["fontSize"]);
184    let global_font_weight = config_string(effective_config, &["fontWeight"]);
185
186    let message_font_family = global_font_family
187        .clone()
188        .or_else(|| config_string(seq_cfg, &["messageFontFamily"]));
189    let message_font_size = global_font_size
190        .or_else(|| config_f64(seq_cfg, &["messageFontSize"]))
191        .unwrap_or(16.0);
192    let message_font_weight = global_font_weight
193        .clone()
194        .or_else(|| config_string(seq_cfg, &["messageFontWeight"]));
195
196    let actor_font_family = global_font_family
197        .clone()
198        .or_else(|| config_string(seq_cfg, &["actorFontFamily"]));
199    let actor_font_size = global_font_size
200        .or_else(|| config_f64(seq_cfg, &["actorFontSize"]))
201        .unwrap_or(16.0);
202    let actor_font_weight = global_font_weight
203        .clone()
204        .or_else(|| config_string(seq_cfg, &["actorFontWeight"]));
205
206    // Upstream sequence uses `calculateTextDimensions(...).width` (SVG `getBBox`) when computing
207    // message widths for spacing. Keep this scale at 1.0 and handle any residual differences via
208    // the SVG-backed `TextMeasurer` implementation.
209    let message_width_scale = 1.0;
210
211    let actor_text_style = TextStyle {
212        font_family: actor_font_family,
213        font_size: actor_font_size,
214        font_weight: actor_font_weight,
215    };
216    let note_font_family = global_font_family
217        .clone()
218        .or_else(|| config_string(seq_cfg, &["noteFontFamily"]));
219    let note_font_size = global_font_size
220        .or_else(|| config_f64(seq_cfg, &["noteFontSize"]))
221        .unwrap_or(16.0);
222    let note_font_weight = global_font_weight
223        .clone()
224        .or_else(|| config_string(seq_cfg, &["noteFontWeight"]));
225    let note_text_style = TextStyle {
226        font_family: note_font_family,
227        font_size: note_font_size,
228        font_weight: note_font_weight,
229    };
230    let msg_text_style = TextStyle {
231        font_family: message_font_family,
232        font_size: message_font_size,
233        font_weight: message_font_weight,
234    };
235
236    let has_boxes = !model.boxes.is_empty();
237    let has_box_titles = model
238        .boxes
239        .iter()
240        .any(|b| b.name.as_deref().is_some_and(|s| !s.trim().is_empty()));
241
242    // Mermaid uses `utils.calculateTextDimensions(...).height` for box titles and stores the max
243    // across boxes in `box.textMaxHeight` (used for bumping actor `starty` when any title exists).
244    //
245    // In Mermaid 11.12.2 with 16px fonts, this height comes out as 17px (not the larger SVG
246    // `getBBox()` height used elsewhere). Keep this model-level constant to match upstream DOM.
247    let max_box_title_height = if has_box_titles {
248        let line_h = sequence_text_overrides::sequence_text_dimensions_height_px(message_font_size);
249        model
250            .boxes
251            .iter()
252            .filter_map(|b| b.name.as_deref())
253            .map(|s| split_html_br_lines(s).len().max(1) as f64 * line_h)
254            .fold(0.0, f64::max)
255    } else {
256        0.0
257    };
258
259    if model.actor_order.is_empty() {
260        return Err(Error::InvalidModel {
261            message: "sequence model has no actorOrder".to_string(),
262        });
263    }
264
265    // Measure participant boxes.
266    let mut actor_widths: Vec<f64> = Vec::with_capacity(model.actor_order.len());
267    let mut actor_base_heights: Vec<f64> = Vec::with_capacity(model.actor_order.len());
268    for id in &model.actor_order {
269        let a = model.actors.get(id).ok_or_else(|| Error::InvalidModel {
270            message: format!("missing actor {id}"),
271        })?;
272        if a.wrap {
273            // Upstream wraps actor descriptions to `conf.width - 2*wrapPadding` and clamps the
274            // actor box width to `conf.width`.
275            let wrap_w = (actor_width_min - 2.0 * wrap_padding).max(1.0);
276            let wrapped_lines =
277                wrap_label_like_mermaid_lines(&a.description, measurer, &actor_text_style, wrap_w);
278            let line_count = wrapped_lines.len().max(1) as f64;
279            let text_h =
280                sequence_text_overrides::sequence_text_dimensions_height_px(actor_font_size)
281                    * line_count;
282            actor_base_heights.push(actor_height.max(text_h).max(1.0));
283            actor_widths.push(actor_width_min.max(1.0));
284        } else {
285            let (w0, _h0) =
286                measure_svg_like_with_html_br(measurer, &a.description, &actor_text_style);
287            let w = (w0 + 2.0 * wrap_padding).max(actor_width_min);
288            actor_base_heights.push(actor_height.max(1.0));
289            actor_widths.push(w.max(1.0));
290        }
291    }
292
293    // Determine the per-actor margins using Mermaid's `getMaxMessageWidthPerActor(...)` rules,
294    // then compute actor x positions from those margins (see upstream `boundActorData`).
295    let mut actor_index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
296    for (i, id) in model.actor_order.iter().enumerate() {
297        actor_index.insert(id.as_str(), i);
298    }
299
300    let mut actor_to_message_width: Vec<f64> = vec![0.0; model.actor_order.len()];
301    for msg in &model.messages {
302        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
303            continue;
304        };
305        let Some(&from_idx) = actor_index.get(from) else {
306            continue;
307        };
308        let Some(&to_idx) = actor_index.get(to) else {
309            continue;
310        };
311
312        let placement = msg.placement;
313        // If this is the first actor, and the note is left of it, no need to calculate the margin.
314        if placement == Some(0) && to_idx == 0 {
315            continue;
316        }
317        // If this is the last actor, and the note is right of it, no need to calculate the margin.
318        if placement == Some(1) && to_idx + 1 == model.actor_order.len() {
319            continue;
320        }
321
322        let is_note = placement.is_some();
323        let is_message = !is_note;
324        let style = if is_note {
325            &note_text_style
326        } else {
327            &msg_text_style
328        };
329        let text = msg.message.as_str().unwrap_or_default();
330        if text.is_empty() {
331            continue;
332        }
333
334        let measured_text = if msg.wrap {
335            // Upstream uses `wrapLabel(message, conf.width - 2*wrapPadding, ...)` when computing
336            // max per-actor message widths for spacing.
337            let wrap_w = (actor_width_min - 2.0 * wrap_padding).max(1.0);
338            let lines = wrap_label_like_mermaid_lines(text, measurer, style, wrap_w);
339            lines.join("<br>")
340        } else {
341            text.to_string()
342        };
343        let (w0, _h0) = measure_svg_like_with_html_br(measurer, &measured_text, style);
344        let w0 = w0 * message_width_scale;
345        let message_w = (w0 + 2.0 * wrap_padding).max(0.0);
346
347        let prev_idx = if to_idx > 0 { Some(to_idx - 1) } else { None };
348        let next_idx = if to_idx + 1 < model.actor_order.len() {
349            Some(to_idx + 1)
350        } else {
351            None
352        };
353
354        if is_message && next_idx.is_some_and(|n| n == from_idx) {
355            actor_to_message_width[to_idx] = actor_to_message_width[to_idx].max(message_w);
356        } else if is_message && prev_idx.is_some_and(|p| p == from_idx) {
357            actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(message_w);
358        } else if is_message && from_idx == to_idx {
359            let half = message_w / 2.0;
360            actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(half);
361            actor_to_message_width[to_idx] = actor_to_message_width[to_idx].max(half);
362        } else if placement == Some(1) {
363            // RIGHTOF
364            actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(message_w);
365        } else if placement == Some(0) {
366            // LEFTOF
367            if let Some(p) = prev_idx {
368                actor_to_message_width[p] = actor_to_message_width[p].max(message_w);
369            }
370        } else if placement == Some(2) {
371            // OVER
372            if let Some(p) = prev_idx {
373                actor_to_message_width[p] = actor_to_message_width[p].max(message_w / 2.0);
374            }
375            if next_idx.is_some() {
376                actor_to_message_width[from_idx] =
377                    actor_to_message_width[from_idx].max(message_w / 2.0);
378            }
379        }
380    }
381
382    let mut actor_margins: Vec<f64> = vec![actor_margin; model.actor_order.len()];
383    for i in 0..model.actor_order.len() {
384        let msg_w = actor_to_message_width[i];
385        if msg_w <= 0.0 {
386            continue;
387        }
388        let w0 = actor_widths[i];
389        let actor_w = if i + 1 < model.actor_order.len() {
390            let w1 = actor_widths[i + 1];
391            msg_w + actor_margin - (w0 / 2.0) - (w1 / 2.0)
392        } else {
393            msg_w + actor_margin - (w0 / 2.0)
394        };
395        actor_margins[i] = actor_w.max(actor_margin);
396    }
397
398    // Mermaid's `calculateActorMargins(...)` computes per-box `box.margin` based on total actor
399    // widths/margins and the box title width. For totalWidth, Mermaid only counts `actor.margin`
400    // if it was set (actors without messages have `margin === undefined` until render-time).
401    let mut box_margins: Vec<f64> = vec![box_text_margin; model.boxes.len()];
402    for (box_idx, b) in model.boxes.iter().enumerate() {
403        let mut total_width = 0.0;
404        for actor_key in &b.actor_keys {
405            let Some(&i) = actor_index.get(actor_key.as_str()) else {
406                continue;
407            };
408            let actor_margin_for_box = if actor_to_message_width[i] > 0.0 {
409                actor_margins[i]
410            } else {
411                0.0
412            };
413            total_width += actor_widths[i] + actor_margin_for_box;
414        }
415
416        total_width += box_margin * 8.0;
417        total_width -= 2.0 * box_text_margin;
418
419        let Some(name) = b.name.as_deref().filter(|s| !s.trim().is_empty()) else {
420            continue;
421        };
422
423        let (text_w, _text_h) = measure_svg_like_with_html_br(measurer, name, &msg_text_style);
424        let min_width = total_width.max(text_w + 2.0 * wrap_padding);
425        if total_width < min_width {
426            box_margins[box_idx] += (min_width - total_width) / 2.0;
427        }
428    }
429
430    // Actors start lower when boxes exist, to make room for box headers.
431    let mut actor_top_offset_y = 0.0;
432    if has_boxes {
433        actor_top_offset_y += box_margin;
434        if has_box_titles {
435            actor_top_offset_y += max_box_title_height;
436        }
437    }
438
439    // Assign each actor to at most one box (Mermaid's db assigns a single `actor.box` reference).
440    let mut actor_box: Vec<Option<usize>> = vec![None; model.actor_order.len()];
441    for (box_idx, b) in model.boxes.iter().enumerate() {
442        for actor_key in &b.actor_keys {
443            let Some(&i) = actor_index.get(actor_key.as_str()) else {
444                continue;
445            };
446            actor_box[i] = Some(box_idx);
447        }
448    }
449
450    let mut actor_left_x: Vec<f64> = Vec::with_capacity(model.actor_order.len());
451    let mut prev_width = 0.0;
452    let mut prev_margin = 0.0;
453    let mut prev_box: Option<usize> = None;
454    for i in 0..model.actor_order.len() {
455        let w = actor_widths[i];
456        let cur_box = actor_box[i];
457
458        // end of box
459        if prev_box.is_some() && prev_box != cur_box {
460            if let Some(prev) = prev_box {
461                prev_margin += box_margin + box_margins[prev];
462            }
463        }
464
465        // new box
466        if cur_box.is_some() && cur_box != prev_box {
467            if let Some(bi) = cur_box {
468                prev_margin += box_margins[bi];
469            }
470        }
471
472        // Mermaid widens the margin before a created actor by `actor.width / 2`.
473        if model.created_actors.contains_key(&model.actor_order[i]) {
474            prev_margin += w / 2.0;
475        }
476        let x = prev_width + prev_margin;
477        actor_left_x.push(x);
478        prev_width += w + prev_margin;
479        prev_margin = actor_margins[i];
480        prev_box = cur_box;
481    }
482
483    let mut actor_centers_x: Vec<f64> = Vec::with_capacity(model.actor_order.len());
484    for i in 0..model.actor_order.len() {
485        actor_centers_x.push(actor_left_x[i] + actor_widths[i] / 2.0);
486    }
487
488    let message_step = message_margin + (message_font_size / 2.0) + bottom_margin_adj;
489    let msg_label_offset = (message_step - message_font_size) + bottom_margin_adj;
490
491    let mut edges: Vec<LayoutEdge> = Vec::new();
492    let mut nodes: Vec<LayoutNode> = Vec::new();
493    let clusters: Vec<LayoutCluster> = Vec::new();
494
495    // Actor boxes: Mermaid renders both a "top" and "bottom" actor box.
496    // The bottom boxes start after all messages are placed. Created actors will have their `y`
497    // adjusted later once we know the creation message position.
498    let mut max_actor_visual_height: f64 = 0.0;
499    for (idx, id) in model.actor_order.iter().enumerate() {
500        let w = actor_widths[idx];
501        let cx = actor_centers_x[idx];
502        let base_h = actor_base_heights[idx];
503        let actor_type = model
504            .actors
505            .get(id)
506            .map(|a| a.actor_type.as_str())
507            .unwrap_or("participant");
508        let visual_h = sequence_actor_visual_height(actor_type, w, base_h, label_box_height);
509        max_actor_visual_height = max_actor_visual_height.max(visual_h.max(1.0));
510        let top_y = actor_top_offset_y + visual_h / 2.0;
511        nodes.push(LayoutNode {
512            id: format!("actor-top-{id}"),
513            x: cx,
514            y: top_y,
515            width: w,
516            height: visual_h,
517            is_cluster: false,
518            label_width: None,
519            label_height: None,
520        });
521    }
522
523    // Message edges.
524
525    fn bracketize(s: &str) -> String {
526        let t = s.trim();
527        if t.is_empty() {
528            return "\u{200B}".to_string();
529        }
530        if t.starts_with('[') && t.ends_with(']') {
531            return t.to_string();
532        }
533        format!("[{t}]")
534    }
535
536    fn block_label_text(raw_label: &str) -> String {
537        bracketize(raw_label)
538    }
539
540    // Mermaid advances the "cursor" for sequence blocks (loop/alt/opt/par/break/critical) even
541    // though these directives are not message edges. The cursor increment depends on the wrapped
542    // block label height; precompute these increments per directive message id.
543    // `adjustLoopHeightForWrap(...)` advances the Mermaid bounds cursor by:
544    // - `preMargin` (either `boxMargin` or `boxMargin + boxTextMargin`)
545    // - plus `heightAdjust`, where `heightAdjust` is:
546    //   - `postMargin` when the block label is empty
547    //   - `postMargin + max(labelTextHeight, labelBoxHeight)` when the label is present
548    //
549    // For the common 1-line label case, this reduces to:
550    //   preMargin + postMargin + labelBoxHeight
551    //
552    // We model this as a base step and subtract `labelBoxHeight` for empty labels.
553    let block_base_step = (2.0 * box_margin + box_text_margin + label_box_height).max(0.0);
554    let block_base_step_empty = (block_base_step - label_box_height).max(0.0);
555    let line_step = sequence_text_overrides::sequence_text_line_step_px(message_font_size);
556    let block_extra_per_line = (line_step - box_text_margin).max(0.0);
557    let block_end_step = 10.0;
558
559    let mut msg_by_id: std::collections::HashMap<&str, &SequenceMessage> =
560        std::collections::HashMap::new();
561    for msg in &model.messages {
562        msg_by_id.insert(msg.id.as_str(), msg);
563    }
564
565    fn is_self_message_id(
566        msg_id: &str,
567        msg_by_id: &std::collections::HashMap<&str, &SequenceMessage>,
568    ) -> bool {
569        let Some(msg) = msg_by_id.get(msg_id).copied() else {
570            return false;
571        };
572        // Notes can use `from==to` for `rightOf`/`leftOf`; do not treat them as self-messages.
573        if msg.message_type == 2 {
574            return false;
575        }
576        msg.from
577            .as_deref()
578            .is_some_and(|from| Some(from) == msg.to.as_deref())
579    }
580
581    fn message_span_x(
582        msg: &SequenceMessage,
583        actor_index: &std::collections::HashMap<&str, usize>,
584        actor_centers_x: &[f64],
585        measurer: &dyn TextMeasurer,
586        msg_text_style: &TextStyle,
587        message_width_scale: f64,
588    ) -> Option<(f64, f64)> {
589        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
590            return None;
591        };
592        let (Some(fi), Some(ti)) = (actor_index.get(from).copied(), actor_index.get(to).copied())
593        else {
594            return None;
595        };
596        let from_x = actor_centers_x[fi];
597        let to_x = actor_centers_x[ti];
598        let sign = if to_x >= from_x { 1.0 } else { -1.0 };
599        let x1 = from_x + sign * 1.0;
600        let x2 = if from == to { x1 } else { to_x - sign * 4.0 };
601        let cx = (x1 + x2) / 2.0;
602
603        let text = msg.message.as_str().unwrap_or_default();
604        let w = if text.is_empty() {
605            1.0
606        } else {
607            let (w, _h) = measure_svg_like_with_html_br(measurer, text, msg_text_style);
608            (w * message_width_scale).max(1.0)
609        };
610        Some((cx - w / 2.0, cx + w / 2.0))
611    }
612
613    fn block_frame_width(
614        message_ids: &[String],
615        msg_by_id: &std::collections::HashMap<&str, &SequenceMessage>,
616        actor_index: &std::collections::HashMap<&str, usize>,
617        actor_centers_x: &[f64],
618        actor_widths: &[f64],
619        message_margin: f64,
620        box_text_margin: f64,
621        bottom_margin_adj: f64,
622        measurer: &dyn TextMeasurer,
623        msg_text_style: &TextStyle,
624        message_width_scale: f64,
625    ) -> Option<f64> {
626        let mut actor_idxs: Vec<usize> = Vec::new();
627        for msg_id in message_ids {
628            let Some(msg) = msg_by_id.get(msg_id.as_str()).copied() else {
629                continue;
630            };
631            let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
632                continue;
633            };
634            if let Some(i) = actor_index.get(from).copied() {
635                actor_idxs.push(i);
636            }
637            if let Some(i) = actor_index.get(to).copied() {
638                actor_idxs.push(i);
639            }
640        }
641        actor_idxs.sort();
642        actor_idxs.dedup();
643        if actor_idxs.is_empty() {
644            return None;
645        }
646
647        if actor_idxs.len() == 1 {
648            let i = actor_idxs[0];
649            let actor_w = actor_widths.get(i).copied().unwrap_or(150.0);
650            let half_width =
651                actor_w / 2.0 + (message_margin / 2.0) + box_text_margin + bottom_margin_adj;
652            let w = (2.0 * half_width).max(1.0);
653            return Some(w);
654        }
655
656        let min_i = actor_idxs.first().copied()?;
657        let max_i = actor_idxs.last().copied()?;
658        let mut x1 = actor_centers_x[min_i] - 11.0;
659        let mut x2 = actor_centers_x[max_i] + 11.0;
660
661        // Expand multi-actor blocks to include overflowing message labels (e.g. long self messages).
662        for msg_id in message_ids {
663            let Some(msg) = msg_by_id.get(msg_id.as_str()).copied() else {
664                continue;
665            };
666            let Some((l, r)) = message_span_x(
667                msg,
668                actor_index,
669                actor_centers_x,
670                measurer,
671                msg_text_style,
672                message_width_scale,
673            ) else {
674                continue;
675            };
676            if l < x1 {
677                x1 = l.floor();
678            }
679            if r > x2 {
680                x2 = r.ceil();
681            }
682        }
683
684        Some((x2 - x1).max(1.0))
685    }
686
687    #[derive(Debug, Clone)]
688    enum BlockStackEntry {
689        Loop {
690            start_id: String,
691            raw_label: String,
692            messages: Vec<String>,
693        },
694        Opt {
695            start_id: String,
696            raw_label: String,
697            messages: Vec<String>,
698        },
699        Break {
700            start_id: String,
701            raw_label: String,
702            messages: Vec<String>,
703        },
704        Alt {
705            section_directives: Vec<(String, String)>,
706            sections: Vec<Vec<String>>,
707        },
708        Par {
709            section_directives: Vec<(String, String)>,
710            sections: Vec<Vec<String>>,
711        },
712        Critical {
713            section_directives: Vec<(String, String)>,
714            sections: Vec<Vec<String>>,
715        },
716    }
717
718    let mut directive_steps: std::collections::HashMap<String, f64> =
719        std::collections::HashMap::new();
720    let mut stack: Vec<BlockStackEntry> = Vec::new();
721    for msg in &model.messages {
722        let raw_label = msg.message.as_str().unwrap_or_default();
723        match msg.message_type {
724            // loop start/end
725            10 => stack.push(BlockStackEntry::Loop {
726                start_id: msg.id.clone(),
727                raw_label: raw_label.to_string(),
728                messages: Vec::new(),
729            }),
730            11 => {
731                if let Some(BlockStackEntry::Loop {
732                    start_id,
733                    raw_label,
734                    messages,
735                }) = stack.pop()
736                {
737                    let loop_has_self_message = messages
738                        .iter()
739                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
740                    let loop_end_step = if loop_has_self_message {
741                        40.0
742                    } else {
743                        block_end_step
744                    };
745
746                    if raw_label.trim().is_empty() {
747                        directive_steps.insert(start_id, block_base_step_empty);
748                    } else if let Some(w) = block_frame_width(
749                        &messages,
750                        &msg_by_id,
751                        &actor_index,
752                        &actor_centers_x,
753                        &actor_widths,
754                        message_margin,
755                        box_text_margin,
756                        bottom_margin_adj,
757                        measurer,
758                        &msg_text_style,
759                        message_width_scale,
760                    ) {
761                        let label = block_label_text(&raw_label);
762                        let metrics = measurer.measure_wrapped(
763                            &label,
764                            &msg_text_style,
765                            Some(w),
766                            WrapMode::SvgLikeSingleRun,
767                        );
768                        let extra =
769                            (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
770                        directive_steps.insert(start_id, block_base_step + extra);
771                    } else {
772                        directive_steps.insert(start_id, block_base_step);
773                    }
774
775                    directive_steps.insert(msg.id.clone(), loop_end_step);
776                }
777            }
778            // opt start/end
779            15 => stack.push(BlockStackEntry::Opt {
780                start_id: msg.id.clone(),
781                raw_label: raw_label.to_string(),
782                messages: Vec::new(),
783            }),
784            16 => {
785                let mut end_step = block_end_step;
786                if let Some(BlockStackEntry::Opt {
787                    start_id,
788                    raw_label,
789                    messages,
790                }) = stack.pop()
791                {
792                    let has_self = messages
793                        .iter()
794                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
795                    end_step = if has_self { 40.0 } else { block_end_step };
796                    if raw_label.trim().is_empty() {
797                        directive_steps.insert(start_id, block_base_step_empty);
798                    } else if let Some(w) = block_frame_width(
799                        &messages,
800                        &msg_by_id,
801                        &actor_index,
802                        &actor_centers_x,
803                        &actor_widths,
804                        message_margin,
805                        box_text_margin,
806                        bottom_margin_adj,
807                        measurer,
808                        &msg_text_style,
809                        message_width_scale,
810                    ) {
811                        let label = block_label_text(&raw_label);
812                        let metrics = measurer.measure_wrapped(
813                            &label,
814                            &msg_text_style,
815                            Some(w),
816                            WrapMode::SvgLikeSingleRun,
817                        );
818                        let extra =
819                            (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
820                        directive_steps.insert(start_id, block_base_step + extra);
821                    } else {
822                        directive_steps.insert(start_id, block_base_step);
823                    }
824                }
825                directive_steps.insert(msg.id.clone(), end_step);
826            }
827            // break start/end
828            30 => stack.push(BlockStackEntry::Break {
829                start_id: msg.id.clone(),
830                raw_label: raw_label.to_string(),
831                messages: Vec::new(),
832            }),
833            31 => {
834                let mut end_step = block_end_step;
835                if let Some(BlockStackEntry::Break {
836                    start_id,
837                    raw_label,
838                    messages,
839                }) = stack.pop()
840                {
841                    let has_self = messages
842                        .iter()
843                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
844                    end_step = if has_self { 40.0 } else { block_end_step };
845                    if raw_label.trim().is_empty() {
846                        directive_steps.insert(start_id, block_base_step_empty);
847                    } else if let Some(w) = block_frame_width(
848                        &messages,
849                        &msg_by_id,
850                        &actor_index,
851                        &actor_centers_x,
852                        &actor_widths,
853                        message_margin,
854                        box_text_margin,
855                        bottom_margin_adj,
856                        measurer,
857                        &msg_text_style,
858                        message_width_scale,
859                    ) {
860                        let label = block_label_text(&raw_label);
861                        let metrics = measurer.measure_wrapped(
862                            &label,
863                            &msg_text_style,
864                            Some(w),
865                            WrapMode::SvgLikeSingleRun,
866                        );
867                        let extra =
868                            (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
869                        directive_steps.insert(start_id, block_base_step + extra);
870                    } else {
871                        directive_steps.insert(start_id, block_base_step);
872                    }
873                }
874                directive_steps.insert(msg.id.clone(), end_step);
875            }
876            // alt start/else/end
877            12 => stack.push(BlockStackEntry::Alt {
878                section_directives: vec![(msg.id.clone(), raw_label.to_string())],
879                sections: vec![Vec::new()],
880            }),
881            13 => {
882                if let Some(BlockStackEntry::Alt {
883                    section_directives,
884                    sections,
885                }) = stack.last_mut()
886                {
887                    section_directives.push((msg.id.clone(), raw_label.to_string()));
888                    sections.push(Vec::new());
889                }
890            }
891            14 => {
892                let mut end_step = block_end_step;
893                if let Some(BlockStackEntry::Alt {
894                    section_directives,
895                    sections,
896                }) = stack.pop()
897                {
898                    let has_self = sections
899                        .iter()
900                        .flatten()
901                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
902                    end_step = if has_self { 40.0 } else { block_end_step };
903                    let mut message_ids: Vec<String> = Vec::new();
904                    for sec in &sections {
905                        message_ids.extend(sec.iter().cloned());
906                    }
907                    if let Some(w) = block_frame_width(
908                        &message_ids,
909                        &msg_by_id,
910                        &actor_index,
911                        &actor_centers_x,
912                        &actor_widths,
913                        message_margin,
914                        box_text_margin,
915                        bottom_margin_adj,
916                        measurer,
917                        &msg_text_style,
918                        message_width_scale,
919                    ) {
920                        for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
921                            let is_empty = raw.trim().is_empty();
922                            if is_empty {
923                                directive_steps.insert(id, block_base_step_empty);
924                                continue;
925                            }
926                            let _ = idx;
927                            let label = block_label_text(&raw);
928                            let metrics = measurer.measure_wrapped(
929                                &label,
930                                &msg_text_style,
931                                Some(w),
932                                WrapMode::SvgLikeSingleRun,
933                            );
934                            let extra = (metrics.line_count.saturating_sub(1) as f64)
935                                * block_extra_per_line;
936                            directive_steps.insert(id, block_base_step + extra);
937                        }
938                    } else {
939                        for (id, raw) in section_directives {
940                            let step = if raw.trim().is_empty() {
941                                block_base_step_empty
942                            } else {
943                                block_base_step
944                            };
945                            directive_steps.insert(id, step);
946                        }
947                    }
948                }
949                directive_steps.insert(msg.id.clone(), end_step);
950            }
951            // par start/and/end
952            19 | 32 => stack.push(BlockStackEntry::Par {
953                section_directives: vec![(msg.id.clone(), raw_label.to_string())],
954                sections: vec![Vec::new()],
955            }),
956            20 => {
957                if let Some(BlockStackEntry::Par {
958                    section_directives,
959                    sections,
960                }) = stack.last_mut()
961                {
962                    section_directives.push((msg.id.clone(), raw_label.to_string()));
963                    sections.push(Vec::new());
964                }
965            }
966            21 => {
967                let mut end_step = block_end_step;
968                if let Some(BlockStackEntry::Par {
969                    section_directives,
970                    sections,
971                }) = stack.pop()
972                {
973                    let has_self = sections
974                        .iter()
975                        .flatten()
976                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
977                    end_step = if has_self { 40.0 } else { block_end_step };
978                    let mut message_ids: Vec<String> = Vec::new();
979                    for sec in &sections {
980                        message_ids.extend(sec.iter().cloned());
981                    }
982                    if let Some(w) = block_frame_width(
983                        &message_ids,
984                        &msg_by_id,
985                        &actor_index,
986                        &actor_centers_x,
987                        &actor_widths,
988                        message_margin,
989                        box_text_margin,
990                        bottom_margin_adj,
991                        measurer,
992                        &msg_text_style,
993                        message_width_scale,
994                    ) {
995                        for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
996                            let is_empty = raw.trim().is_empty();
997                            if is_empty {
998                                directive_steps.insert(id, block_base_step_empty);
999                                continue;
1000                            }
1001                            let _ = idx;
1002                            let label = block_label_text(&raw);
1003                            let metrics = measurer.measure_wrapped(
1004                                &label,
1005                                &msg_text_style,
1006                                Some(w),
1007                                WrapMode::SvgLikeSingleRun,
1008                            );
1009                            let extra = (metrics.line_count.saturating_sub(1) as f64)
1010                                * block_extra_per_line;
1011                            directive_steps.insert(id, block_base_step + extra);
1012                        }
1013                    } else {
1014                        for (id, raw) in section_directives {
1015                            let step = if raw.trim().is_empty() {
1016                                block_base_step_empty
1017                            } else {
1018                                block_base_step
1019                            };
1020                            directive_steps.insert(id, step);
1021                        }
1022                    }
1023                }
1024                directive_steps.insert(msg.id.clone(), end_step);
1025            }
1026            // critical start/option/end
1027            27 => stack.push(BlockStackEntry::Critical {
1028                section_directives: vec![(msg.id.clone(), raw_label.to_string())],
1029                sections: vec![Vec::new()],
1030            }),
1031            28 => {
1032                if let Some(BlockStackEntry::Critical {
1033                    section_directives,
1034                    sections,
1035                }) = stack.last_mut()
1036                {
1037                    section_directives.push((msg.id.clone(), raw_label.to_string()));
1038                    sections.push(Vec::new());
1039                }
1040            }
1041            29 => {
1042                let mut end_step = block_end_step;
1043                if let Some(BlockStackEntry::Critical {
1044                    section_directives,
1045                    sections,
1046                }) = stack.pop()
1047                {
1048                    let has_self = sections
1049                        .iter()
1050                        .flatten()
1051                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
1052                    end_step = if has_self { 40.0 } else { block_end_step };
1053                    let mut message_ids: Vec<String> = Vec::new();
1054                    for sec in &sections {
1055                        message_ids.extend(sec.iter().cloned());
1056                    }
1057                    if let Some(w) = block_frame_width(
1058                        &message_ids,
1059                        &msg_by_id,
1060                        &actor_index,
1061                        &actor_centers_x,
1062                        &actor_widths,
1063                        message_margin,
1064                        box_text_margin,
1065                        bottom_margin_adj,
1066                        measurer,
1067                        &msg_text_style,
1068                        message_width_scale,
1069                    ) {
1070                        for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
1071                            let is_empty = raw.trim().is_empty();
1072                            if is_empty {
1073                                directive_steps.insert(id, block_base_step_empty);
1074                                continue;
1075                            }
1076                            let _ = idx;
1077                            let label = block_label_text(&raw);
1078                            let metrics = measurer.measure_wrapped(
1079                                &label,
1080                                &msg_text_style,
1081                                Some(w),
1082                                WrapMode::SvgLikeSingleRun,
1083                            );
1084                            let extra = (metrics.line_count.saturating_sub(1) as f64)
1085                                * block_extra_per_line;
1086                            directive_steps.insert(id, block_base_step + extra);
1087                        }
1088                    } else {
1089                        for (id, raw) in section_directives {
1090                            let step = if raw.trim().is_empty() {
1091                                block_base_step_empty
1092                            } else {
1093                                block_base_step
1094                            };
1095                            directive_steps.insert(id, step);
1096                        }
1097                    }
1098                }
1099                directive_steps.insert(msg.id.clone(), end_step);
1100            }
1101            _ => {
1102                // If this is a "real" message edge, attach it to all active block scopes so block
1103                // width computations can account for overflowing message labels.
1104                if msg.from.is_some() && msg.to.is_some() {
1105                    for entry in stack.iter_mut() {
1106                        match entry {
1107                            BlockStackEntry::Alt { sections, .. }
1108                            | BlockStackEntry::Par { sections, .. }
1109                            | BlockStackEntry::Critical { sections, .. } => {
1110                                if let Some(cur) = sections.last_mut() {
1111                                    cur.push(msg.id.clone());
1112                                }
1113                            }
1114                            BlockStackEntry::Loop { messages, .. }
1115                            | BlockStackEntry::Opt { messages, .. }
1116                            | BlockStackEntry::Break { messages, .. } => {
1117                                messages.push(msg.id.clone());
1118                            }
1119                        }
1120                    }
1121                }
1122            }
1123        }
1124    }
1125
1126    #[derive(Debug, Clone)]
1127    struct RectOpen {
1128        start_id: String,
1129        top_y: f64,
1130        bounds: Option<merman_core::geom::Box2>,
1131    }
1132
1133    impl RectOpen {
1134        fn include_min_max(&mut self, min_x: f64, max_x: f64, max_y: f64) {
1135            let r = merman_core::geom::Box2::from_min_max(min_x, self.top_y, max_x, max_y);
1136            if let Some(ref mut cur) = self.bounds {
1137                cur.union(r);
1138            } else {
1139                self.bounds = Some(r);
1140            }
1141        }
1142    }
1143
1144    // Mermaid's sequence renderer advances a "cursor" even for non-message directives (notes,
1145    // rect blocks). To avoid overlapping bottom actors and to match upstream viewBox sizes, we
1146    // model these increments in headless layout as well.
1147    let note_width_single = actor_width_min;
1148    let rect_step_start = 20.0;
1149    let rect_step_end = 10.0;
1150    let note_gap = 10.0;
1151    // Mermaid note boxes use 10px vertical padding on both sides (20px total), on top of the
1152    // SVG `getBBox().height` of the note text.
1153    let note_text_pad_total = sequence_text_overrides::sequence_note_text_pad_total_px();
1154    let note_top_offset = message_step - note_gap;
1155
1156    let mut cursor_y = actor_top_offset_y + max_actor_visual_height + message_step;
1157    let mut rect_stack: Vec<RectOpen> = Vec::new();
1158    let activation_width = config_f64(seq_cfg, &["activationWidth"])
1159        .unwrap_or(10.0)
1160        .max(1.0);
1161    let mut activation_stacks: std::collections::BTreeMap<&str, Vec<f64>> =
1162        std::collections::BTreeMap::new();
1163
1164    // Mermaid adjusts created/destroyed actors while processing messages:
1165    // - created actor: `starty = lineStartY - actor.height/2`
1166    // - destroyed actor: `stopy = lineStartY - actor.height/2`
1167    // It also bumps the cursor by `actor.height/2` to avoid overlaps.
1168    let mut created_actor_top_center_y: std::collections::BTreeMap<String, f64> =
1169        std::collections::BTreeMap::new();
1170    let mut destroyed_actor_bottom_top_y: std::collections::BTreeMap<String, f64> =
1171        std::collections::BTreeMap::new();
1172
1173    let actor_visual_height_for_id = |actor_id: &str| -> f64 {
1174        let Some(idx) = actor_index.get(actor_id).copied() else {
1175            return actor_height.max(1.0);
1176        };
1177        let w = actor_widths.get(idx).copied().unwrap_or(actor_width_min);
1178        let base_h = actor_base_heights.get(idx).copied().unwrap_or(actor_height);
1179        model
1180            .actors
1181            .get(actor_id)
1182            .map(|a| a.actor_type.as_str())
1183            .map(|t| sequence_actor_visual_height(t, w, base_h, label_box_height))
1184            .unwrap_or(base_h.max(1.0))
1185    };
1186    let actor_is_type_width_limited = |actor_id: &str| -> bool {
1187        model
1188            .actors
1189            .get(actor_id)
1190            .map(|a| {
1191                matches!(
1192                    a.actor_type.as_str(),
1193                    "actor" | "control" | "entity" | "database"
1194                )
1195            })
1196            .unwrap_or(false)
1197    };
1198
1199    for (msg_idx, msg) in model.messages.iter().enumerate() {
1200        match msg.message_type {
1201            // ACTIVE_START
1202            17 => {
1203                let Some(actor_id) = msg.from.as_deref() else {
1204                    continue;
1205                };
1206                let Some(&idx) = actor_index.get(actor_id) else {
1207                    continue;
1208                };
1209                let cx = actor_centers_x[idx];
1210                let stack = activation_stacks.entry(actor_id).or_default();
1211                let stacked_size = stack.len();
1212                let startx = cx + (((stacked_size as f64) - 1.0) * activation_width) / 2.0;
1213                stack.push(startx);
1214                continue;
1215            }
1216            // ACTIVE_END
1217            18 => {
1218                let Some(actor_id) = msg.from.as_deref() else {
1219                    continue;
1220                };
1221                if let Some(stack) = activation_stacks.get_mut(actor_id) {
1222                    let _ = stack.pop();
1223                }
1224                continue;
1225            }
1226            _ => {}
1227        }
1228
1229        if let Some(step) = directive_steps.get(msg.id.as_str()).copied() {
1230            cursor_y += step;
1231            continue;
1232        }
1233        match msg.message_type {
1234            // rect start: advances cursor but draws later as a background `<rect>`.
1235            22 => {
1236                rect_stack.push(RectOpen {
1237                    start_id: msg.id.clone(),
1238                    top_y: cursor_y - note_top_offset,
1239                    bounds: None,
1240                });
1241                cursor_y += rect_step_start;
1242                continue;
1243            }
1244            // rect end
1245            23 => {
1246                if let Some(open) = rect_stack.pop() {
1247                    let rect_left = open.bounds.map(|b| b.min_x()).unwrap_or_else(|| {
1248                        actor_centers_x
1249                            .iter()
1250                            .copied()
1251                            .fold(f64::INFINITY, f64::min)
1252                            - 11.0
1253                    });
1254                    let rect_right = open.bounds.map(|b| b.max_x()).unwrap_or_else(|| {
1255                        actor_centers_x
1256                            .iter()
1257                            .copied()
1258                            .fold(f64::NEG_INFINITY, f64::max)
1259                            + 11.0
1260                    });
1261                    let rect_bottom = open
1262                        .bounds
1263                        .map(|b| b.max_y() + 10.0)
1264                        .unwrap_or(open.top_y + 10.0);
1265                    let rect_w = (rect_right - rect_left).max(1.0);
1266                    let rect_h = (rect_bottom - open.top_y).max(1.0);
1267
1268                    nodes.push(LayoutNode {
1269                        id: format!("rect-{}", open.start_id),
1270                        x: rect_left + rect_w / 2.0,
1271                        y: open.top_y + rect_h / 2.0,
1272                        width: rect_w,
1273                        height: rect_h,
1274                        is_cluster: false,
1275                        label_width: None,
1276                        label_height: None,
1277                    });
1278
1279                    if let Some(parent) = rect_stack.last_mut() {
1280                        parent.include_min_max(rect_left - 10.0, rect_right + 10.0, rect_bottom);
1281                    }
1282                }
1283                cursor_y += rect_step_end;
1284                continue;
1285            }
1286            _ => {}
1287        }
1288
1289        // Notes (type=2) are laid out as nodes, not message edges.
1290        if msg.message_type == 2 {
1291            let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1292                continue;
1293            };
1294            let (Some(fi), Some(ti)) =
1295                (actor_index.get(from).copied(), actor_index.get(to).copied())
1296            else {
1297                continue;
1298            };
1299            let fx = actor_centers_x[fi];
1300            let tx = actor_centers_x[ti];
1301
1302            let placement = msg.placement.unwrap_or(2);
1303            let (mut note_x, mut note_w) = match placement {
1304                // leftOf
1305                0 => (fx - 25.0 - note_width_single, note_width_single),
1306                // rightOf
1307                1 => (fx + 25.0, note_width_single),
1308                // over
1309                _ => {
1310                    if (fx - tx).abs() < 0.0001 {
1311                        // Mermaid's `buildNoteModel(...)` widens "over self" notes when `wrap: true`:
1312                        //   noteModel.width = max(conf.width, fromActor.width)
1313                        //
1314                        // This is observable in upstream SVG baselines for participants with
1315                        // type-driven widths (e.g. `queue`), where the note box matches the actor
1316                        // width rather than the configured default `conf.width`.
1317                        let mut w = note_width_single;
1318                        if msg.wrap {
1319                            w = w.max(actor_widths.get(fi).copied().unwrap_or(note_width_single));
1320                        }
1321                        (fx - (w / 2.0), w)
1322                    } else {
1323                        let left = fx.min(tx) - 25.0;
1324                        let right = fx.max(tx) + 25.0;
1325                        let w = (right - left).max(note_width_single);
1326                        (left, w)
1327                    }
1328                }
1329            };
1330
1331            let text = msg.message.as_str().unwrap_or_default();
1332            let (text_w, h) = if msg.wrap {
1333                // Mermaid Sequence notes are wrapped via `wrapLabel(...)`, then measured via SVG
1334                // bbox probes (not HTML wrapping). Model this by producing wrapped `<br/>` lines
1335                // and then measuring them.
1336                //
1337                // Important: Mermaid widens *leftOf* wrapped notes based on the initially wrapped
1338                // text width (+ margins) before re-wrapping to the final width. This affects the
1339                // final wrap width and thus the rendered line breaks.
1340                let w0 = {
1341                    let init_lines = wrap_label_like_mermaid_lines_floored_bbox(
1342                        text,
1343                        measurer,
1344                        &note_text_style,
1345                        (note_width_single
1346                            + sequence_text_overrides::sequence_note_wrap_slack_px())
1347                        .max(1.0),
1348                    );
1349                    let init_wrapped = init_lines.join("<br/>");
1350                    let (w, _h) =
1351                        measure_svg_like_with_html_br(measurer, &init_wrapped, &note_text_style);
1352                    w.max(0.0)
1353                };
1354
1355                if placement == 0 {
1356                    // Mermaid (LEFTOF + wrap): `noteModel.width = max(conf.width, textWidth + 2*noteMargin)`.
1357                    // Our note padding total is `2*noteMargin`/`2*wrapPadding` in the default config.
1358                    note_w = note_w.max((w0 + note_text_pad_total).round().max(1.0));
1359                    note_x = fx - 25.0 - note_w;
1360                }
1361
1362                let wrap_w = (note_w - note_text_pad_total).max(1.0);
1363                let lines = wrap_label_like_mermaid_lines_floored_bbox(
1364                    text,
1365                    measurer,
1366                    &note_text_style,
1367                    (wrap_w + sequence_text_overrides::sequence_note_wrap_slack_px()).max(1.0),
1368                );
1369                let wrapped = lines.join("<br/>");
1370                let (w, h) = measure_svg_like_with_html_br(measurer, &wrapped, &note_text_style);
1371                (w.max(0.0), h.max(0.0))
1372            } else {
1373                measure_svg_like_with_html_br(measurer, text, &note_text_style)
1374            };
1375
1376            // Mermaid's `buildNoteModel(...)` widens the note box when the text would overflow the
1377            // configured default width. This is observable in strict SVG XML baselines when the
1378            // note contains literal `<br ...>` markup that is *not* treated as a line break.
1379            let padded_w = (text_w + note_text_pad_total).round().max(1.0);
1380            if !msg.wrap {
1381                match placement {
1382                    // leftOf / rightOf notes clamp width to fit label text.
1383                    0 | 1 => {
1384                        note_w = note_w.max(padded_w);
1385                    }
1386                    // over: only clamp when the note is over a single actor (`from == to`).
1387                    _ => {
1388                        if (fx - tx).abs() < 0.0001 {
1389                            note_w = note_w.max(padded_w);
1390                        }
1391                    }
1392                }
1393            }
1394            let note_h = (h + note_text_pad_total).round().max(1.0);
1395            let note_y = (cursor_y - note_top_offset).round();
1396
1397            nodes.push(LayoutNode {
1398                id: format!("note-{}", msg.id),
1399                x: note_x + note_w / 2.0,
1400                y: note_y + note_h / 2.0,
1401                width: note_w.max(1.0),
1402                height: note_h,
1403                is_cluster: false,
1404                label_width: None,
1405                label_height: None,
1406            });
1407
1408            for open in rect_stack.iter_mut() {
1409                open.include_min_max(note_x - 10.0, note_x + note_w + 10.0, note_y + note_h);
1410            }
1411
1412            cursor_y += note_h + note_gap;
1413            continue;
1414        }
1415
1416        // Regular message edges.
1417        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1418            continue;
1419        };
1420        let (Some(fi), Some(ti)) = (actor_index.get(from).copied(), actor_index.get(to).copied())
1421        else {
1422            continue;
1423        };
1424        let from_x = actor_centers_x[fi];
1425        let to_x = actor_centers_x[ti];
1426
1427        let (from_left, from_right) = activation_stacks
1428            .get(from)
1429            .and_then(|s| s.last().copied())
1430            .map(|startx| (startx, startx + activation_width))
1431            .unwrap_or((from_x - 1.0, from_x + 1.0));
1432
1433        let (to_left, to_right) = activation_stacks
1434            .get(to)
1435            .and_then(|s| s.last().copied())
1436            .map(|startx| (startx, startx + activation_width))
1437            .unwrap_or((to_x - 1.0, to_x + 1.0));
1438
1439        let is_arrow_to_right = from_left <= to_left;
1440        let mut startx = if is_arrow_to_right {
1441            from_right
1442        } else {
1443            from_left
1444        };
1445        let mut stopx = if is_arrow_to_right { to_left } else { to_right };
1446
1447        let adjust_value = |v: f64| if is_arrow_to_right { -v } else { v };
1448        let is_arrow_to_activation = (to_left - to_right).abs() > 2.0;
1449
1450        let is_self = from == to;
1451        if is_self {
1452            stopx = startx;
1453        } else {
1454            if msg.activate && !is_arrow_to_activation {
1455                stopx += adjust_value(activation_width / 2.0 - 1.0);
1456            }
1457
1458            if !matches!(msg.message_type, 5 | 6) {
1459                stopx += adjust_value(3.0);
1460            }
1461
1462            if matches!(msg.message_type, 33 | 34) {
1463                startx -= adjust_value(3.0);
1464            }
1465        }
1466
1467        if !is_self {
1468            // Mermaid adjusts creating/destroying messages so arrowheads land outside the actor box.
1469            const ACTOR_TYPE_WIDTH_HALF: f64 = 18.0;
1470            if model
1471                .created_actors
1472                .get(to)
1473                .is_some_and(|&idx| idx == msg_idx)
1474            {
1475                let adjustment = if actor_is_type_width_limited(to) {
1476                    ACTOR_TYPE_WIDTH_HALF + 3.0
1477                } else {
1478                    actor_widths[ti] / 2.0 + 3.0
1479                };
1480                if to_x < from_x {
1481                    stopx += adjustment;
1482                } else {
1483                    stopx -= adjustment;
1484                }
1485            } else if model
1486                .destroyed_actors
1487                .get(from)
1488                .is_some_and(|&idx| idx == msg_idx)
1489            {
1490                let adjustment = if actor_is_type_width_limited(from) {
1491                    ACTOR_TYPE_WIDTH_HALF
1492                } else {
1493                    actor_widths[fi] / 2.0
1494                };
1495                if from_x < to_x {
1496                    startx += adjustment;
1497                } else {
1498                    startx -= adjustment;
1499                }
1500            } else if model
1501                .destroyed_actors
1502                .get(to)
1503                .is_some_and(|&idx| idx == msg_idx)
1504            {
1505                let adjustment = if actor_is_type_width_limited(to) {
1506                    ACTOR_TYPE_WIDTH_HALF + 3.0
1507                } else {
1508                    actor_widths[ti] / 2.0 + 3.0
1509                };
1510                if to_x < from_x {
1511                    stopx += adjustment;
1512                } else {
1513                    stopx -= adjustment;
1514                }
1515            }
1516        }
1517
1518        let text = msg.message.as_str().unwrap_or_default();
1519        let bounded_width = (startx - stopx).abs().max(0.0);
1520        let wrapped_text = if !text.is_empty() && msg.wrap {
1521            // Upstream wraps message labels to `max(boundedWidth + 2*wrapPadding, conf.width)`.
1522            // Note: a small extra margin helps keep wrap breakpoints aligned with upstream SVG
1523            // baselines for long sentences under our vendored metrics.
1524            let wrap_w = (bounded_width + 3.0 * wrap_padding)
1525                .max(actor_width_min)
1526                .max(1.0);
1527            let lines =
1528                wrap_label_like_mermaid_lines_floored_bbox(text, measurer, &msg_text_style, wrap_w);
1529            Some(lines.join("<br>"))
1530        } else {
1531            None
1532        };
1533        let effective_text = wrapped_text.as_deref().unwrap_or(text);
1534
1535        let (line_y, label_base_y, cursor_step) = if effective_text.is_empty() {
1536            // Mermaid's `boundMessage(...)` uses the measured text bbox height. For empty labels
1537            // (trailing colon `Alice->Bob:`) the bbox height becomes 0, collapsing the extra
1538            // vertical offset and producing a much earlier message line.
1539            //
1540            // Our cursor model uses `message_step` (a typical 1-line height) as the baseline.
1541            // Shift the line up and only advance by `boxMargin` to match the upstream footer actor
1542            // placement and overall viewBox height.
1543            let line_y = cursor_y - (message_step - box_margin);
1544            (line_y, cursor_y, box_margin)
1545        } else {
1546            // Mermaid's `boundMessage(...)` uses `common.splitBreaks(message)` to derive a
1547            // `lines` count and adjusts the message line y-position and cursor increment by the
1548            // per-line height. This applies both to explicit `<br>` breaks and to `wrap: true`
1549            // labels (which are wrapped via `wrapLabel(...)` and stored with `<br/>` separators).
1550            let lines = split_html_br_lines(effective_text).len().max(1);
1551            // Mermaid's `calculateTextDimensions(...).height` is consistently ~2px smaller per
1552            // line than the rendered `drawText(...)` getBBox, so use a bbox-like per-line height
1553            // for the cursor math here.
1554            let bbox_line_h = (message_font_size + bottom_margin_adj).max(0.0);
1555            let extra = (lines.saturating_sub(1) as f64) * bbox_line_h;
1556            (cursor_y + extra, cursor_y, message_step + extra)
1557        };
1558
1559        let x1 = startx;
1560        let x2 = stopx;
1561
1562        let label = if effective_text.is_empty() {
1563            // Mermaid renders an (empty) message text node even when the label is empty (e.g.
1564            // trailing colon `Alice->Bob:`). Keep a placeholder label to preserve DOM structure.
1565            Some(LayoutLabel {
1566                x: ((x1 + x2) / 2.0).round(),
1567                y: (label_base_y - msg_label_offset).round(),
1568                width: 1.0,
1569                height: message_font_size.max(1.0),
1570            })
1571        } else {
1572            let (w, h) = measure_svg_like_with_html_br(measurer, effective_text, &msg_text_style);
1573            Some(LayoutLabel {
1574                x: ((x1 + x2) / 2.0).round(),
1575                y: (label_base_y - msg_label_offset).round(),
1576                width: (w * message_width_scale).max(1.0),
1577                height: h.max(1.0),
1578            })
1579        };
1580
1581        edges.push(LayoutEdge {
1582            id: format!("msg-{}", msg.id),
1583            from: from.to_string(),
1584            to: to.to_string(),
1585            from_cluster: None,
1586            to_cluster: None,
1587            points: vec![
1588                LayoutPoint { x: x1, y: line_y },
1589                LayoutPoint { x: x2, y: line_y },
1590            ],
1591            label,
1592            start_label_left: None,
1593            start_label_right: None,
1594            end_label_left: None,
1595            end_label_right: None,
1596            start_marker: None,
1597            end_marker: None,
1598            stroke_dasharray: None,
1599        });
1600
1601        for open in rect_stack.iter_mut() {
1602            let lx = from_x.min(to_x) - 11.0;
1603            let rx = from_x.max(to_x) + 11.0;
1604            open.include_min_max(lx, rx, line_y);
1605        }
1606
1607        cursor_y += cursor_step;
1608        if is_self {
1609            // Mermaid adds extra vertical space for self-messages to accommodate the loop curve.
1610            cursor_y += 30.0;
1611        }
1612
1613        // Apply Mermaid's created/destroyed actor y adjustments and spacing bumps.
1614        if model
1615            .created_actors
1616            .get(to)
1617            .is_some_and(|&idx| idx == msg_idx)
1618        {
1619            let h = actor_visual_height_for_id(to);
1620            created_actor_top_center_y.insert(to.to_string(), line_y);
1621            cursor_y += h / 2.0;
1622        } else if model
1623            .destroyed_actors
1624            .get(from)
1625            .is_some_and(|&idx| idx == msg_idx)
1626        {
1627            let h = actor_visual_height_for_id(from);
1628            destroyed_actor_bottom_top_y.insert(from.to_string(), line_y - h / 2.0);
1629            cursor_y += h / 2.0;
1630        } else if model
1631            .destroyed_actors
1632            .get(to)
1633            .is_some_and(|&idx| idx == msg_idx)
1634        {
1635            let h = actor_visual_height_for_id(to);
1636            destroyed_actor_bottom_top_y.insert(to.to_string(), line_y - h / 2.0);
1637            cursor_y += h / 2.0;
1638        }
1639    }
1640
1641    let bottom_margin = message_margin - message_font_size + bottom_margin_adj;
1642    let bottom_box_top_y = (cursor_y - message_step) + bottom_margin;
1643
1644    // Apply created-actor `starty` overrides now that we know the creation message y.
1645    for n in nodes.iter_mut() {
1646        let Some(actor_id) = n.id.strip_prefix("actor-top-") else {
1647            continue;
1648        };
1649        if let Some(y) = created_actor_top_center_y.get(actor_id).copied() {
1650            n.y = y;
1651        }
1652    }
1653
1654    for (idx, id) in model.actor_order.iter().enumerate() {
1655        let w = actor_widths[idx];
1656        let cx = actor_centers_x[idx];
1657        let base_h = actor_base_heights[idx];
1658        let actor_type = model
1659            .actors
1660            .get(id)
1661            .map(|a| a.actor_type.as_str())
1662            .unwrap_or("participant");
1663        let visual_h = sequence_actor_visual_height(actor_type, w, base_h, label_box_height);
1664        let bottom_top_y = destroyed_actor_bottom_top_y
1665            .get(id)
1666            .copied()
1667            .unwrap_or(bottom_box_top_y);
1668        let bottom_visual_h = if mirror_actors { visual_h } else { 0.0 };
1669        nodes.push(LayoutNode {
1670            id: format!("actor-bottom-{id}"),
1671            x: cx,
1672            y: bottom_top_y + bottom_visual_h / 2.0,
1673            width: w,
1674            height: bottom_visual_h,
1675            is_cluster: false,
1676            label_width: None,
1677            label_height: None,
1678        });
1679
1680        let top_center_y = created_actor_top_center_y
1681            .get(id)
1682            .copied()
1683            .unwrap_or(actor_top_offset_y + visual_h / 2.0);
1684        let top_left_y = top_center_y - visual_h / 2.0;
1685        let lifeline_start_y =
1686            top_left_y + sequence_actor_lifeline_start_y(actor_type, base_h, box_text_margin);
1687
1688        edges.push(LayoutEdge {
1689            id: format!("lifeline-{id}"),
1690            from: format!("actor-top-{id}"),
1691            to: format!("actor-bottom-{id}"),
1692            from_cluster: None,
1693            to_cluster: None,
1694            points: vec![
1695                LayoutPoint {
1696                    x: cx,
1697                    y: lifeline_start_y,
1698                },
1699                LayoutPoint {
1700                    x: cx,
1701                    y: bottom_top_y,
1702                },
1703            ],
1704            label: None,
1705            start_label_left: None,
1706            start_label_right: None,
1707            end_label_left: None,
1708            end_label_right: None,
1709            start_marker: None,
1710            end_marker: None,
1711            stroke_dasharray: None,
1712        });
1713    }
1714
1715    // Mermaid's SVG `viewBox` is derived from `svg.getBBox()` plus diagram margins. Block frames
1716    // (`alt`, `par`, `loop`, `opt`, `break`, `critical`) can extend beyond the node/edge graph we
1717    // model in headless layout. Capture their extents so we can expand bounds before emitting the
1718    // final `viewBox`.
1719    let block_bounds = {
1720        use std::collections::HashMap;
1721
1722        let nodes_by_id: HashMap<&str, &LayoutNode> = nodes
1723            .iter()
1724            .map(|n| (n.id.as_str(), n))
1725            .collect::<HashMap<_, _>>();
1726        let edges_by_id: HashMap<&str, &LayoutEdge> = edges
1727            .iter()
1728            .map(|e| (e.id.as_str(), e))
1729            .collect::<HashMap<_, _>>();
1730
1731        let mut msg_endpoints: HashMap<&str, (&str, &str)> = HashMap::new();
1732        for msg in &model.messages {
1733            let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1734                continue;
1735            };
1736            msg_endpoints.insert(msg.id.as_str(), (from, to));
1737        }
1738
1739        fn item_y_range(
1740            item_id: &str,
1741            nodes_by_id: &HashMap<&str, &LayoutNode>,
1742            edges_by_id: &HashMap<&str, &LayoutEdge>,
1743            msg_endpoints: &HashMap<&str, (&str, &str)>,
1744        ) -> Option<(f64, f64)> {
1745            // Mermaid's self-message branch expands bounds by 60px below the message line y
1746            // coordinate (see the `+ 30 + totalOffset` bottom coordinate, where `totalOffset`
1747            // already includes a `+30` bump).
1748            let edge_id = format!("msg-{item_id}");
1749            if let Some(e) = edges_by_id.get(edge_id.as_str()).copied() {
1750                let y = e.points.first()?.y;
1751                let extra = msg_endpoints
1752                    .get(item_id)
1753                    .copied()
1754                    .filter(|(from, to)| from == to)
1755                    .map(|_| sequence_text_overrides::sequence_self_message_frame_extra_y_px())
1756                    .unwrap_or(0.0);
1757                return Some((y, y + extra));
1758            }
1759
1760            let node_id = format!("note-{item_id}");
1761            let n = nodes_by_id.get(node_id.as_str()).copied()?;
1762            let top = n.y - n.height / 2.0;
1763            let bottom = n.y + n.height / 2.0;
1764            Some((top, bottom))
1765        }
1766
1767        fn frame_x_from_item_ids<'a>(
1768            item_ids: impl IntoIterator<Item = &'a String>,
1769            nodes_by_id: &HashMap<&str, &LayoutNode>,
1770            edges_by_id: &HashMap<&str, &LayoutEdge>,
1771            msg_endpoints: &HashMap<&str, (&str, &str)>,
1772        ) -> Option<(f64, f64, f64)> {
1773            let mut min_cx = f64::INFINITY;
1774            let mut max_cx = f64::NEG_INFINITY;
1775            let mut min_left = f64::INFINITY;
1776            let mut geom_min_x = f64::INFINITY;
1777            let mut geom_max_x = f64::NEG_INFINITY;
1778
1779            for id in item_ids {
1780                // Notes contribute directly via their node bounds.
1781                let note_id = format!("note-{id}");
1782                if let Some(n) = nodes_by_id.get(note_id.as_str()).copied() {
1783                    geom_min_x = geom_min_x.min(
1784                        n.x - n.width / 2.0 - sequence_text_overrides::sequence_frame_geom_pad_px(),
1785                    );
1786                    geom_max_x = geom_max_x.max(
1787                        n.x + n.width / 2.0 + sequence_text_overrides::sequence_frame_geom_pad_px(),
1788                    );
1789                }
1790
1791                let Some((from, to)) = msg_endpoints.get(id.as_str()).copied() else {
1792                    continue;
1793                };
1794                for actor_id in [from, to] {
1795                    let actor_node_id = format!("actor-top-{actor_id}");
1796                    let Some(n) = nodes_by_id.get(actor_node_id.as_str()).copied() else {
1797                        continue;
1798                    };
1799                    min_cx = min_cx.min(n.x);
1800                    max_cx = max_cx.max(n.x);
1801                    min_left = min_left.min(n.x - n.width / 2.0);
1802                }
1803
1804                // Message edges can overflow via label widths.
1805                let edge_id = format!("msg-{id}");
1806                if let Some(e) = edges_by_id.get(edge_id.as_str()).copied() {
1807                    for p in &e.points {
1808                        geom_min_x = geom_min_x.min(p.x);
1809                        geom_max_x = geom_max_x.max(p.x);
1810                    }
1811                    if let Some(label) = e.label.as_ref() {
1812                        geom_min_x = geom_min_x.min(
1813                            label.x
1814                                - (label.width / 2.0)
1815                                - sequence_text_overrides::sequence_frame_geom_pad_px(),
1816                        );
1817                        geom_max_x = geom_max_x.max(
1818                            label.x
1819                                + (label.width / 2.0)
1820                                + sequence_text_overrides::sequence_frame_geom_pad_px(),
1821                        );
1822                    }
1823                }
1824            }
1825
1826            if !min_cx.is_finite() || !max_cx.is_finite() {
1827                return None;
1828            }
1829            let mut x1 = min_cx - sequence_text_overrides::sequence_frame_side_pad_px();
1830            let mut x2 = max_cx + sequence_text_overrides::sequence_frame_side_pad_px();
1831            if geom_min_x.is_finite() {
1832                x1 = x1.min(geom_min_x);
1833            }
1834            if geom_max_x.is_finite() {
1835                x2 = x2.max(geom_max_x);
1836            }
1837            Some((x1, x2, min_left))
1838        }
1839
1840        #[derive(Debug)]
1841        enum BlockStackEntry {
1842            Loop { items: Vec<String> },
1843            Opt { items: Vec<String> },
1844            Break { items: Vec<String> },
1845            Alt { sections: Vec<Vec<String>> },
1846            Par { sections: Vec<Vec<String>> },
1847            Critical { sections: Vec<Vec<String>> },
1848        }
1849
1850        let mut block_min_x = f64::INFINITY;
1851        let mut block_min_y = f64::INFINITY;
1852        let mut block_max_x = f64::NEG_INFINITY;
1853        let mut block_max_y = f64::NEG_INFINITY;
1854
1855        let mut stack: Vec<BlockStackEntry> = Vec::new();
1856        for msg in &model.messages {
1857            let msg_id = msg.id.clone();
1858            match msg.message_type {
1859                10 => stack.push(BlockStackEntry::Loop { items: Vec::new() }),
1860                11 => {
1861                    if let Some(BlockStackEntry::Loop { items }) = stack.pop() {
1862                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1863                            frame_x_from_item_ids(
1864                                &items,
1865                                &nodes_by_id,
1866                                &edges_by_id,
1867                                &msg_endpoints,
1868                            ),
1869                            items
1870                                .iter()
1871                                .filter_map(|id| {
1872                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1873                                })
1874                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1875                        ) {
1876                            let frame_y1 = y0 - 79.0;
1877                            let frame_y2 = y1 + 10.0;
1878                            block_min_x = block_min_x.min(x1);
1879                            block_max_x = block_max_x.max(x2);
1880                            block_min_y = block_min_y.min(frame_y1);
1881                            block_max_y = block_max_y.max(frame_y2);
1882                        }
1883                    }
1884                }
1885                15 => stack.push(BlockStackEntry::Opt { items: Vec::new() }),
1886                16 => {
1887                    if let Some(BlockStackEntry::Opt { items }) = stack.pop() {
1888                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1889                            frame_x_from_item_ids(
1890                                &items,
1891                                &nodes_by_id,
1892                                &edges_by_id,
1893                                &msg_endpoints,
1894                            ),
1895                            items
1896                                .iter()
1897                                .filter_map(|id| {
1898                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1899                                })
1900                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1901                        ) {
1902                            let frame_y1 = y0 - 79.0;
1903                            let frame_y2 = y1 + 10.0;
1904                            block_min_x = block_min_x.min(x1);
1905                            block_max_x = block_max_x.max(x2);
1906                            block_min_y = block_min_y.min(frame_y1);
1907                            block_max_y = block_max_y.max(frame_y2);
1908                        }
1909                    }
1910                }
1911                30 => stack.push(BlockStackEntry::Break { items: Vec::new() }),
1912                31 => {
1913                    if let Some(BlockStackEntry::Break { items }) = stack.pop() {
1914                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1915                            frame_x_from_item_ids(
1916                                &items,
1917                                &nodes_by_id,
1918                                &edges_by_id,
1919                                &msg_endpoints,
1920                            ),
1921                            items
1922                                .iter()
1923                                .filter_map(|id| {
1924                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1925                                })
1926                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1927                        ) {
1928                            let frame_y1 = y0 - 93.0;
1929                            let frame_y2 = y1 + 10.0;
1930                            block_min_x = block_min_x.min(x1);
1931                            block_max_x = block_max_x.max(x2);
1932                            block_min_y = block_min_y.min(frame_y1);
1933                            block_max_y = block_max_y.max(frame_y2);
1934                        }
1935                    }
1936                }
1937                12 => stack.push(BlockStackEntry::Alt {
1938                    sections: vec![Vec::new()],
1939                }),
1940                13 => {
1941                    if let Some(BlockStackEntry::Alt { sections }) = stack.last_mut() {
1942                        sections.push(Vec::new());
1943                    }
1944                }
1945                14 => {
1946                    if let Some(BlockStackEntry::Alt { sections }) = stack.pop() {
1947                        let items: Vec<String> = sections.into_iter().flatten().collect();
1948                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1949                            frame_x_from_item_ids(
1950                                &items,
1951                                &nodes_by_id,
1952                                &edges_by_id,
1953                                &msg_endpoints,
1954                            ),
1955                            items
1956                                .iter()
1957                                .filter_map(|id| {
1958                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1959                                })
1960                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1961                        ) {
1962                            let frame_y1 = y0 - 79.0;
1963                            let frame_y2 = y1 + 10.0;
1964                            block_min_x = block_min_x.min(x1);
1965                            block_max_x = block_max_x.max(x2);
1966                            block_min_y = block_min_y.min(frame_y1);
1967                            block_max_y = block_max_y.max(frame_y2);
1968                        }
1969                    }
1970                }
1971                19 | 32 => stack.push(BlockStackEntry::Par {
1972                    sections: vec![Vec::new()],
1973                }),
1974                20 => {
1975                    if let Some(BlockStackEntry::Par { sections }) = stack.last_mut() {
1976                        sections.push(Vec::new());
1977                    }
1978                }
1979                21 => {
1980                    if let Some(BlockStackEntry::Par { sections }) = stack.pop() {
1981                        let items: Vec<String> = sections.into_iter().flatten().collect();
1982                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1983                            frame_x_from_item_ids(
1984                                &items,
1985                                &nodes_by_id,
1986                                &edges_by_id,
1987                                &msg_endpoints,
1988                            ),
1989                            items
1990                                .iter()
1991                                .filter_map(|id| {
1992                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1993                                })
1994                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1995                        ) {
1996                            let frame_y1 = y0 - 79.0;
1997                            let frame_y2 = y1 + 10.0;
1998                            block_min_x = block_min_x.min(x1);
1999                            block_max_x = block_max_x.max(x2);
2000                            block_min_y = block_min_y.min(frame_y1);
2001                            block_max_y = block_max_y.max(frame_y2);
2002                        }
2003                    }
2004                }
2005                27 => stack.push(BlockStackEntry::Critical {
2006                    sections: vec![Vec::new()],
2007                }),
2008                28 => {
2009                    if let Some(BlockStackEntry::Critical { sections }) = stack.last_mut() {
2010                        sections.push(Vec::new());
2011                    }
2012                }
2013                29 => {
2014                    if let Some(BlockStackEntry::Critical { sections }) = stack.pop() {
2015                        let section_count = sections.len();
2016                        let items: Vec<String> = sections.into_iter().flatten().collect();
2017                        if let (Some((mut x1, x2, min_left)), Some((y0, y1))) = (
2018                            frame_x_from_item_ids(
2019                                &items,
2020                                &nodes_by_id,
2021                                &edges_by_id,
2022                                &msg_endpoints,
2023                            ),
2024                            items
2025                                .iter()
2026                                .filter_map(|id| {
2027                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
2028                                })
2029                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
2030                        ) {
2031                            if min_left.is_finite() && !items.is_empty() && section_count > 1 {
2032                                x1 = x1.min(min_left - 9.0);
2033                            }
2034                            let frame_y1 = y0 - 79.0;
2035                            let frame_y2 = y1 + 10.0;
2036                            block_min_x = block_min_x.min(x1);
2037                            block_max_x = block_max_x.max(x2);
2038                            block_min_y = block_min_y.min(frame_y1);
2039                            block_max_y = block_max_y.max(frame_y2);
2040                        }
2041                    }
2042                }
2043                2 => {
2044                    for entry in stack.iter_mut() {
2045                        match entry {
2046                            BlockStackEntry::Alt { sections }
2047                            | BlockStackEntry::Par { sections }
2048                            | BlockStackEntry::Critical { sections } => {
2049                                if let Some(cur) = sections.last_mut() {
2050                                    cur.push(msg_id.clone());
2051                                }
2052                            }
2053                            BlockStackEntry::Loop { items }
2054                            | BlockStackEntry::Opt { items }
2055                            | BlockStackEntry::Break { items } => {
2056                                items.push(msg_id.clone());
2057                            }
2058                        }
2059                    }
2060                }
2061                _ => {
2062                    if msg.from.is_some() && msg.to.is_some() {
2063                        for entry in stack.iter_mut() {
2064                            match entry {
2065                                BlockStackEntry::Alt { sections }
2066                                | BlockStackEntry::Par { sections }
2067                                | BlockStackEntry::Critical { sections } => {
2068                                    if let Some(cur) = sections.last_mut() {
2069                                        cur.push(msg_id.clone());
2070                                    }
2071                                }
2072                                BlockStackEntry::Loop { items }
2073                                | BlockStackEntry::Opt { items }
2074                                | BlockStackEntry::Break { items } => {
2075                                    items.push(msg_id.clone());
2076                                }
2077                            }
2078                        }
2079                    }
2080                }
2081            }
2082        }
2083
2084        if block_min_x.is_finite() && block_min_y.is_finite() {
2085            Some((block_min_x, block_min_y, block_max_x, block_max_y))
2086        } else {
2087            None
2088        }
2089    };
2090
2091    let mut content_min_x = f64::INFINITY;
2092    let mut content_max_x = f64::NEG_INFINITY;
2093    let mut content_max_y = f64::NEG_INFINITY;
2094    for n in &nodes {
2095        let left = n.x - n.width / 2.0;
2096        let right = n.x + n.width / 2.0;
2097        let bottom = n.y + n.height / 2.0;
2098        content_min_x = content_min_x.min(left);
2099        content_max_x = content_max_x.max(right);
2100        content_max_y = content_max_y.max(bottom);
2101    }
2102    if !content_min_x.is_finite() {
2103        content_min_x = 0.0;
2104        content_max_x = actor_width_min.max(1.0);
2105        content_max_y = (bottom_box_top_y + actor_height).max(1.0);
2106    }
2107
2108    if let Some((min_x, _min_y, max_x, max_y)) = block_bounds {
2109        content_min_x = content_min_x.min(min_x);
2110        content_max_x = content_max_x.max(max_x);
2111        content_max_y = content_max_y.max(max_y);
2112    }
2113
2114    // Mermaid (11.12.2) expands the viewBox vertically when a sequence title is present.
2115    // See `sequenceRenderer.ts`: `extraVertForTitle = title ? 40 : 0`.
2116    let extra_vert_for_title = if model.title.is_some() { 40.0 } else { 0.0 };
2117
2118    // Mermaid's sequence renderer sets the viewBox y origin to `-(diagramMarginY + extraVertForTitle)`
2119    // regardless of diagram contents.
2120    let vb_min_y = -(diagram_margin_y + extra_vert_for_title);
2121
2122    // Mermaid's sequence renderer uses a bounds box with `starty = 0` and computes `height` from
2123    // `stopy - starty`. Our headless layout models message spacing in content coordinates, but for
2124    // viewBox parity we must follow the upstream formula.
2125    //
2126    // When boxes exist, Mermaid's bounds logic ends up extending the vertical bounds by `boxMargin`
2127    // (diagramMarginY covers the remaining box padding), so include it here.
2128    let mut bounds_box_stopy = (content_max_y + bottom_margin_adj).max(0.0);
2129    if has_boxes {
2130        bounds_box_stopy += box_margin;
2131    }
2132
2133    // Mermaid's bounds box includes the per-box inner margins (`box.margin`) when boxes exist.
2134    // Approximate this by extending actor bounds by their enclosing box margin.
2135    let mut bounds_box_startx = content_min_x;
2136    let mut bounds_box_stopx = content_max_x;
2137    for i in 0..model.actor_order.len() {
2138        let left = actor_left_x[i];
2139        let right = left + actor_widths[i];
2140        if let Some(bi) = actor_box[i] {
2141            let m = box_margins[bi];
2142            bounds_box_startx = bounds_box_startx.min(left - m);
2143            bounds_box_stopx = bounds_box_stopx.max(right + m);
2144        } else {
2145            bounds_box_startx = bounds_box_startx.min(left);
2146            bounds_box_stopx = bounds_box_stopx.max(right);
2147        }
2148    }
2149
2150    // Mermaid's self-message bounds insert expands horizontally by `dx = max(textWidth/2, conf.width/2)`,
2151    // where `conf.width` is the configured actor width (150 by default). This can increase `box.stopx`
2152    // by ~1px due to `from_x + 1` rounding behavior in message geometry, affecting viewBox width.
2153    for msg in &model.messages {
2154        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
2155            continue;
2156        };
2157        if from != to {
2158            continue;
2159        }
2160        // Notes can use `from==to` for `rightOf`/`leftOf`; ignore them here.
2161        if msg.message_type == 2 {
2162            continue;
2163        }
2164        let Some(&i) = actor_index.get(from) else {
2165            continue;
2166        };
2167        let center_x = actor_centers_x[i] + 1.0;
2168        let text = msg.message.as_str().unwrap_or_default();
2169        let (text_w, _text_h) = if text.is_empty() {
2170            (1.0, 1.0)
2171        } else {
2172            measure_svg_like_with_html_br(measurer, text, &msg_text_style)
2173        };
2174        let dx = (text_w.max(1.0) / 2.0).max(actor_width_min / 2.0);
2175        bounds_box_startx = bounds_box_startx.min(center_x - dx);
2176        bounds_box_stopx = bounds_box_stopx.max(center_x + dx);
2177    }
2178
2179    let bounds = Some(Bounds {
2180        min_x: bounds_box_startx - diagram_margin_x,
2181        min_y: vb_min_y,
2182        max_x: bounds_box_stopx + diagram_margin_x,
2183        max_y: bounds_box_stopy + diagram_margin_y,
2184    });
2185
2186    Ok(SequenceDiagramLayout {
2187        nodes,
2188        edges,
2189        clusters,
2190        bounds,
2191    })
2192}
2193
2194#[cfg(test)]
2195mod tests {
2196    use crate::generated::sequence_text_overrides_11_12_2 as sequence_text_overrides;
2197
2198    #[test]
2199    fn sequence_text_constants_are_generated() {
2200        assert_eq!(sequence_text_overrides::sequence_note_wrap_slack_px(), 12.0);
2201        assert_eq!(
2202            sequence_text_overrides::sequence_text_dimensions_height_px(16.0),
2203            17.0
2204        );
2205        assert_eq!(
2206            sequence_text_overrides::sequence_text_line_step_px(16.0),
2207            19.0
2208        );
2209        assert_eq!(
2210            sequence_text_overrides::sequence_note_text_pad_total_px(),
2211            20.0
2212        );
2213        assert_eq!(
2214            sequence_text_overrides::sequence_self_message_frame_extra_y_px(),
2215            60.0
2216        );
2217        assert_eq!(
2218            sequence_text_overrides::sequence_self_message_separator_extra_y_px(),
2219            30.0
2220        );
2221        assert_eq!(sequence_text_overrides::sequence_frame_side_pad_px(), 11.0);
2222        assert_eq!(sequence_text_overrides::sequence_frame_geom_pad_px(), 10.0);
2223        assert_eq!(
2224            sequence_text_overrides::sequence_self_only_frame_min_pad_left_px(),
2225            5.0
2226        );
2227        assert_eq!(
2228            sequence_text_overrides::sequence_self_only_frame_min_pad_right_px(),
2229            15.0
2230        );
2231    }
2232}