osd_core/
renderer.rs

1//! SVG renderer for sequence diagrams
2
3use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8/// Rendering configuration
9#[derive(Debug, Clone)]
10pub struct Config {
11    /// Padding around the diagram
12    pub padding: f64,
13    /// Left margin for diagram content
14    pub left_margin: f64,
15    /// Right margin for diagram content
16    pub right_margin: f64,
17    /// Space between participants
18    pub participant_gap: f64,
19    /// Height of participant header/footer box
20    pub header_height: f64,
21    /// Height of each row (message, note, etc.)
22    pub row_height: f64,
23    /// Width of participant box
24    pub participant_width: f64,
25    /// Font size
26    pub font_size: f64,
27    /// Activation box width
28    pub activation_width: f64,
29    /// Note padding
30    pub note_padding: f64,
31    /// Block margin
32    pub block_margin: f64,
33    /// Title height (when title exists)
34    pub title_height: f64,
35    /// Theme for styling
36    pub theme: Theme,
37}
38
39impl Default for Config {
40    fn default() -> Self {
41        Self {
42            padding: 10.5,
43            left_margin: 40.0,
44            right_margin: 40.0,
45            participant_gap: 150.0,
46            header_height: 40.0,
47            row_height: 32.0,
48            participant_width: 90.0,
49            font_size: 12.0,
50            activation_width: 10.0,
51            note_padding: 6.0,
52            block_margin: 5.0,
53            title_height: 50.0,
54            theme: Theme::default(),
55        }
56    }
57}
58
59impl Config {
60    /// Set the theme
61    pub fn with_theme(mut self, theme: Theme) -> Self {
62        self.theme = theme;
63        self
64    }
65}
66
67/// Block background info for deferred rendering
68#[derive(Debug, Clone)]
69struct BlockBackground {
70    x: f64,
71    y: f64,
72    width: f64,
73    height: f64,
74}
75
76/// Block label info for deferred rendering (rendered above activations/lifelines)
77#[derive(Debug, Clone)]
78struct BlockLabel {
79    x1: f64,
80    start_y: f64,
81    end_y: f64,
82    x2: f64,
83    kind: String,
84    label: String,
85    else_y: Option<f64>,
86}
87
88#[derive(Debug, Clone)]
89struct LabelBox {
90    x_min: f64,
91    x_max: f64,
92    y_min: f64,
93    y_max: f64,
94}
95
96/// Render state
97struct RenderState {
98    config: Config,
99    participants: Vec<Participant>,
100    participant_x: HashMap<String, f64>,
101    participant_widths: HashMap<String, f64>,
102    current_y: f64,
103    activations: HashMap<String, Vec<(f64, Option<f64>)>>,
104    autonumber: Option<u32>,
105    destroyed: HashMap<String, f64>,
106    has_title: bool,
107    total_width: f64,
108    /// Collected block backgrounds for deferred rendering
109    block_backgrounds: Vec<BlockBackground>,
110    /// Collected block labels for deferred rendering (above activations/lifelines)
111    block_labels: Vec<BlockLabel>,
112    /// Footer style from diagram options
113    footer_style: FooterStyle,
114    /// Tracks whether the first return message in an else branch needs extra spacing
115    else_return_pending: Vec<bool>,
116    /// Tracks whether a serial block needs extra spacing after its first row
117    serial_first_row_pending: Vec<bool>,
118    /// Tracks nested parallel depth for serial row spacing
119    parallel_depth: usize,
120    /// Tracks message label bounding boxes to avoid overlap
121    message_label_boxes: Vec<LabelBox>,
122}
123
124const TEXT_WIDTH_PADDING: f64 = 41.0;
125const TEXT_WIDTH_SCALE: f64 = 1.3;
126const MESSAGE_WIDTH_PADDING: f64 = 8.0;
127const MESSAGE_WIDTH_SCALE: f64 = 0.9;
128const DELAY_UNIT: f64 = 18.0;
129const BLOCK_LABEL_HEIGHT: f64 = 24.0;
130const BLOCK_FOOTER_PADDING_LEVEL1: f64 = 1.5;
131const BLOCK_FOOTER_PADDING_DEEP: f64 = 1.5;
132const BLOCK_FOOTER_PADDING_TOP_FACTOR: f64 = 1.8;
133const BLOCK_ELSE_SPACING_LEVEL1: f64 = 1.1875;
134const BLOCK_ELSE_SPACING_DEEP: f64 = 1.15625;
135const BLOCK_ELSE_TOP_SPACING_FACTOR: f64 = 1.15625;
136const BLOCK_NESTED_HEADER_ADJUST: f64 = 18.0;
137const BLOCK_NESTED_FRAME_SHIFT: f64 = 18.0;
138const PARALLEL_BLOCK_GAP: f64 = 22.0;
139const MESSAGE_SPACING_MULT: f64 = 0.5625;
140const SELF_MESSAGE_MIN_SPACING: f64 = 78.0;
141const SELF_MESSAGE_GAP: f64 = 16.0;
142const CREATE_MESSAGE_SPACING: f64 = 41.0;
143const DESTROY_SPACING: f64 = 15.0;
144const NOTE_PADDING: f64 = 9.5;
145const NOTE_LINE_HEIGHT_EXTRA: f64 = 6.0;
146const NOTE_MARGIN: f64 = 24.0;
147const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0;
148const REF_LINE_HEIGHT_EXTRA: f64 = 16.333333;
149const ELSE_RETURN_GAP: f64 = 1.0;
150const SERIAL_FIRST_ROW_GAP: f64 = 0.0;
151const SERIAL_FIRST_ROW_PARALLEL_GAP: f64 = 1.0;
152const SERIAL_SELF_MESSAGE_ADJUST: f64 = 1.0;
153const ACTIVATION_START_GAP: f64 = 0.0;
154const ACTIVATION_CHAIN_GAP: f64 = 1.0;
155const REF_EXTRA_GAP: f64 = 2.5;
156const SELF_MESSAGE_ACTIVE_ADJUST: f64 = 1.0;
157const STATE_EXTRA_GAP: f64 = 0.0;
158const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
159const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
160const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
161const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
162
163fn block_header_space(config: &Config, depth: usize) -> f64 {
164    let base = config.row_height + BLOCK_LABEL_HEIGHT;
165    if depth == 0 {
166        base
167    } else {
168        (base - BLOCK_NESTED_HEADER_ADJUST).max(BLOCK_LABEL_HEIGHT)
169    }
170}
171
172fn block_frame_shift(depth: usize) -> f64 {
173    if depth == 0 {
174        0.0
175    } else if depth == 1 {
176        BLOCK_NESTED_FRAME_SHIFT
177    } else {
178        BLOCK_NESTED_FRAME_SHIFT
179    }
180}
181
182fn block_footer_padding(config: &Config, depth: usize) -> f64 {
183    let factor = if depth == 0 {
184        BLOCK_FOOTER_PADDING_TOP_FACTOR
185    } else if depth == 1 {
186        BLOCK_FOOTER_PADDING_LEVEL1
187    } else {
188        BLOCK_FOOTER_PADDING_DEEP
189    };
190    config.row_height * factor
191}
192
193fn block_else_spacing(config: &Config, depth: usize) -> f64 {
194    if depth == 0 {
195        config.row_height * BLOCK_ELSE_TOP_SPACING_FACTOR
196    } else if depth == 1 {
197        config.row_height * BLOCK_ELSE_SPACING_LEVEL1
198    } else {
199        config.row_height * BLOCK_ELSE_SPACING_DEEP
200    }
201}
202
203fn message_spacing_line_height(config: &Config) -> f64 {
204    config.row_height * MESSAGE_SPACING_MULT
205}
206
207fn self_message_spacing(config: &Config, lines: usize) -> f64 {
208    let line_height = config.font_size + 4.0;
209    let text_block_height = lines as f64 * line_height;
210    let loop_height = (text_block_height + 10.0).max(25.0);
211    let base = loop_height + SELF_MESSAGE_GAP;
212    if lines >= 3 {
213        base.max(SELF_MESSAGE_MIN_SPACING)
214    } else {
215        base
216    }
217}
218
219fn note_line_height(config: &Config) -> f64 {
220    config.font_size + NOTE_LINE_HEIGHT_EXTRA
221}
222
223fn note_padding(_config: &Config) -> f64 {
224    NOTE_PADDING
225}
226
227fn item_pre_gap(config: &Config) -> f64 {
228    config.font_size + 1.0
229}
230
231fn item_pre_shift(config: &Config) -> f64 {
232    (config.row_height - item_pre_gap(config)).max(0.0)
233}
234
235fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
236    let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
237        && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
238    let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
239        && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
240    x_overlap && y_overlap
241}
242
243fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
244    // Actor names are now rendered within the header, so no extra footer space needed
245    0.0
246}
247
248fn serial_first_row_gap(parallel_depth: usize) -> f64 {
249    if parallel_depth > 0 {
250        SERIAL_FIRST_ROW_PARALLEL_GAP
251    } else {
252        SERIAL_FIRST_ROW_GAP
253    }
254}
255
256fn state_line_height(config: &Config) -> f64 {
257    config.font_size + STATE_LINE_HEIGHT_EXTRA
258}
259
260fn ref_line_height(config: &Config) -> f64 {
261    config.font_size + REF_LINE_HEIGHT_EXTRA
262}
263
264/// Arrowhead size constant
265const ARROWHEAD_SIZE: f64 = 10.0;
266
267/// Generate arrowhead polygon points for a given end position and direction
268fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
269    let size = ARROWHEAD_SIZE;
270    let half_width = size * 0.35;
271
272    // Tip of the arrow
273    let tip_x = x;
274    let tip_y = y;
275
276    // Back points of the arrow (rotated by direction)
277    let back_x = x - size * direction.cos();
278    let back_y = y - size * direction.sin();
279
280    // Perpendicular offset for the two back points
281    let perp_x = -direction.sin() * half_width;
282    let perp_y = direction.cos() * half_width;
283
284    format!(
285        "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
286        back_x + perp_x,
287        back_y + perp_y,
288        tip_x,
289        tip_y,
290        back_x - perp_x,
291        back_y - perp_y
292    )
293}
294
295/// Calculate direction angle from (x1, y1) to (x2, y2)
296fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
297    (y2 - y1).atan2(x2 - x1)
298}
299
300fn block_has_frame(kind: &BlockKind) -> bool {
301    !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
302}
303
304fn block_is_parallel(kind: &BlockKind) -> bool {
305    matches!(kind, BlockKind::Parallel)
306}
307
308fn parallel_needs_gap(items: &[Item]) -> bool {
309    items.iter().any(|item| matches!(item, Item::Block { .. }))
310}
311
312fn text_char_weight(c: char) -> f64 {
313    if c.is_ascii() {
314        if c.is_uppercase() {
315            0.7
316        } else {
317            0.5
318        }
319    } else {
320        1.0 // CJK and other characters are wider
321    }
322}
323
324fn max_weighted_line(text: &str) -> f64 {
325    text.split("\\n")
326        .map(|line| line.chars().map(text_char_weight).sum::<f64>())
327        .fold(0.0_f64, |a, b| a.max(b))
328}
329
330/// Estimate text width in pixels (rough approximation)
331fn estimate_text_width(text: &str, font_size: f64) -> f64 {
332    let weighted = max_weighted_line(text);
333    weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
334}
335
336fn estimate_message_width(text: &str, font_size: f64) -> f64 {
337    let weighted = max_weighted_line(text);
338    weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
339}
340
341fn block_tab_width(kind: &str) -> f64 {
342    (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
343}
344
345/// Calculate dynamic gaps between participants based on message text lengths
346fn calculate_participant_gaps(
347    participants: &[Participant],
348    items: &[Item],
349    config: &Config,
350) -> Vec<f64> {
351    if participants.len() <= 1 {
352        return vec![];
353    }
354
355    // Create a map from participant id to index
356    let mut participant_index: HashMap<String, usize> = HashMap::new();
357    for (i, p) in participants.iter().enumerate() {
358        participant_index.insert(p.id().to_string(), i);
359    }
360
361    // Initialize gaps with minimum gap
362    let min_gap = config.participant_gap * 0.6; // Minimum gap (60% of default)
363    let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
364
365    // Calculate max text width for each adjacent pair
366    fn process_items(
367        items: &[Item],
368        participant_index: &HashMap<String, usize>,
369        gaps: &mut Vec<f64>,
370        config: &Config,
371    ) {
372        for item in items {
373            match item {
374                Item::Message { from, to, text, .. } => {
375                    if let (Some(&from_idx), Some(&to_idx)) =
376                        (participant_index.get(from), participant_index.get(to))
377                    {
378                        if from_idx != to_idx {
379                            let (min_idx, max_idx) = if from_idx < to_idx {
380                                (from_idx, to_idx)
381                            } else {
382                                (to_idx, from_idx)
383                            };
384
385                            let text_width = estimate_message_width(text, config.font_size);
386
387                            // Distribute needed width across gaps between the participants
388                            let gap_count = (max_idx - min_idx) as f64;
389                            let needed_gap =
390                                text_width / gap_count + config.participant_width * 0.3;
391
392                            // Update gaps between the participants
393                            for gap_idx in min_idx..max_idx {
394                                if needed_gap > gaps[gap_idx] {
395                                    gaps[gap_idx] = needed_gap;
396                                }
397                            }
398                        }
399                    }
400                }
401                Item::Block {
402                    items, else_items, ..
403                } => {
404                    process_items(items, participant_index, gaps, config);
405                    if let Some(else_items) = else_items {
406                        process_items(else_items, participant_index, gaps, config);
407                    }
408                }
409                _ => {}
410            }
411        }
412    }
413
414    process_items(items, &participant_index, &mut gaps, config);
415
416    // Also consider participant name lengths
417    for i in 0..participants.len() - 1 {
418        let name1_width = estimate_message_width(&participants[i].name, config.font_size);
419        let name2_width = estimate_message_width(&participants[i + 1].name, config.font_size);
420        let needed_for_names = (name1_width + name2_width) / 2.0 + 20.0;
421        if needed_for_names > gaps[i] {
422            gaps[i] = needed_for_names;
423        }
424    }
425
426    // Cap maximum gap
427    let max_gap = config.participant_gap * 3.0;
428    for gap in &mut gaps {
429        if *gap > max_gap {
430            *gap = max_gap;
431        }
432    }
433
434    gaps
435}
436
437impl RenderState {
438    fn new(
439        config: Config,
440        participants: Vec<Participant>,
441        items: &[Item],
442        has_title: bool,
443        footer_style: FooterStyle,
444    ) -> Self {
445        let mut config = config;
446        let line_height = config.font_size + 2.0;
447        let mut required_header_height = config.header_height;
448        for p in &participants {
449            let lines = p.name.split("\\n").count();
450            let total_height = lines as f64 * line_height;
451            let needed = match p.kind {
452                ParticipantKind::Participant => total_height + 10.0,
453                ParticipantKind::Actor => {
454                    // Stick figure (38px) + name below + margins
455                    let figure_height = 38.0;
456                    let name_height = lines as f64 * line_height;
457                    figure_height + name_height + 15.0
458                }
459            };
460            if needed > required_header_height {
461                required_header_height = needed;
462            }
463        }
464        if required_header_height > config.header_height {
465            config.header_height = required_header_height;
466        }
467        // Calculate individual participant widths based on their names
468        let mut participant_widths: HashMap<String, f64> = HashMap::new();
469        let min_width = config.participant_width;
470
471        for p in &participants {
472            let text_width = estimate_text_width(&p.name, config.font_size);
473            let width = (text_width + 8.0).max(min_width);
474            participant_widths.insert(p.id().to_string(), width);
475        }
476
477        let gaps = calculate_participant_gaps(&participants, items, &config);
478
479        // Left margin for notes/actions on leftmost participant
480        let left_margin = config.left_margin;
481        // Right margin for self-loops and notes on rightmost participant
482        let right_margin = config.right_margin;
483
484        let mut participant_x = HashMap::new();
485        let first_width = participants
486            .first()
487            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
488            .unwrap_or(min_width);
489        let mut current_x = config.padding + left_margin + first_width / 2.0;
490
491        for (i, p) in participants.iter().enumerate() {
492            participant_x.insert(p.id().to_string(), current_x);
493            if i < gaps.len() {
494                let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
495                let next_width = participants
496                    .get(i + 1)
497                    .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
498                    .unwrap_or(min_width);
499                // Gap is between the edges of adjacent participants
500                let actual_gap = gaps[i].max((current_width + next_width) / 2.0 + 30.0);
501                current_x += actual_gap;
502            }
503        }
504
505        let last_width = participants
506            .last()
507            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
508            .unwrap_or(min_width);
509        let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
510
511        Self {
512            config,
513            participants,
514            participant_x,
515            participant_widths,
516            current_y: 0.0,
517            activations: HashMap::new(),
518            autonumber: None,
519            destroyed: HashMap::new(),
520            has_title,
521            total_width,
522            block_backgrounds: Vec::new(),
523            block_labels: Vec::new(),
524            footer_style,
525            else_return_pending: Vec::new(),
526            serial_first_row_pending: Vec::new(),
527            parallel_depth: 0,
528            message_label_boxes: Vec::new(),
529        }
530    }
531
532    fn get_participant_width(&self, name: &str) -> f64 {
533        *self
534            .participant_widths
535            .get(name)
536            .unwrap_or(&self.config.participant_width)
537    }
538
539    fn get_x(&self, name: &str) -> f64 {
540        *self.participant_x.get(name).unwrap_or(&0.0)
541    }
542
543    fn push_else_return_pending(&mut self) {
544        self.else_return_pending.push(true);
545    }
546
547    fn pop_else_return_pending(&mut self) {
548        self.else_return_pending.pop();
549    }
550
551    fn apply_else_return_gap(&mut self, arrow: &Arrow) {
552        if let Some(pending) = self.else_return_pending.last_mut() {
553            if *pending && matches!(arrow.line, LineStyle::Dashed) {
554                self.current_y += ELSE_RETURN_GAP;
555                *pending = false;
556            }
557        }
558    }
559
560    fn push_serial_first_row_pending(&mut self) {
561        self.serial_first_row_pending.push(true);
562    }
563
564    fn pop_serial_first_row_pending(&mut self) {
565        self.serial_first_row_pending.pop();
566    }
567
568    fn in_serial_block(&self) -> bool {
569        !self.serial_first_row_pending.is_empty()
570    }
571
572    fn apply_serial_first_row_gap(&mut self) {
573        if let Some(pending) = self.serial_first_row_pending.last_mut() {
574            if *pending {
575                self.current_y += serial_first_row_gap(self.parallel_depth);
576                *pending = false;
577            }
578        }
579    }
580
581    fn reserve_message_label(
582        &mut self,
583        x_min: f64,
584        x_max: f64,
585        mut y_min: f64,
586        mut y_max: f64,
587        step: f64,
588    ) -> f64 {
589        let mut offset = 0.0;
590        let mut attempts = 0;
591        while self
592            .message_label_boxes
593            .iter()
594            .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
595            && attempts < 20
596        {
597            y_min += step;
598            y_max += step;
599            offset += step;
600            attempts += 1;
601        }
602        self.message_label_boxes.push(LabelBox {
603            x_min,
604            x_max,
605            y_min,
606            y_max,
607        });
608        offset
609    }
610
611    fn push_parallel(&mut self) {
612        self.parallel_depth += 1;
613    }
614
615    fn pop_parallel(&mut self) {
616        if self.parallel_depth > 0 {
617            self.parallel_depth -= 1;
618        }
619    }
620
621    fn active_activation_count(&self) -> usize {
622        self.activations
623            .values()
624            .map(|acts| acts.iter().filter(|(_, end)| end.is_none()).count())
625            .sum()
626    }
627
628    fn diagram_width(&self) -> f64 {
629        self.total_width
630    }
631
632    /// Get the x position of the leftmost participant
633    fn leftmost_x(&self) -> f64 {
634        self.participants
635            .first()
636            .map(|p| self.get_x(p.id()))
637            .unwrap_or(self.config.padding)
638    }
639
640    /// Get the x position of the rightmost participant
641    fn rightmost_x(&self) -> f64 {
642        self.participants
643            .last()
644            .map(|p| self.get_x(p.id()))
645            .unwrap_or(self.total_width - self.config.padding)
646    }
647
648    /// Get block left boundary (based on leftmost participant)
649    fn block_left(&self) -> f64 {
650        let leftmost_width = self
651            .participants
652            .first()
653            .map(|p| self.get_participant_width(p.id()))
654            .unwrap_or(self.config.participant_width);
655        self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
656    }
657
658    /// Get block right boundary (based on rightmost participant)
659    fn block_right(&self) -> f64 {
660        let rightmost_width = self
661            .participants
662            .last()
663            .map(|p| self.get_participant_width(p.id()))
664            .unwrap_or(self.config.participant_width);
665        self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
666    }
667
668    fn header_top(&self) -> f64 {
669        if self.has_title {
670            self.config.padding + self.config.title_height
671        } else {
672            self.config.padding
673        }
674    }
675
676    fn content_start(&self) -> f64 {
677        self.header_top() + self.config.header_height + self.config.row_height
678    }
679
680    fn next_number(&mut self) -> Option<u32> {
681        self.autonumber.map(|n| {
682            self.autonumber = Some(n + 1);
683            n
684        })
685    }
686
687    /// Add a block background to be rendered later
688    fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
689        self.block_backgrounds.push(BlockBackground {
690            x,
691            y,
692            width,
693            height,
694        });
695    }
696
697    /// Add a block label to be rendered later (above activations/lifelines)
698    fn add_block_label(
699        &mut self,
700        x1: f64,
701        start_y: f64,
702        end_y: f64,
703        x2: f64,
704        kind: &str,
705        label: &str,
706        else_y: Option<f64>,
707    ) {
708        self.block_labels.push(BlockLabel {
709            x1,
710            start_y,
711            end_y,
712            x2,
713            kind: kind.to_string(),
714            label: label.to_string(),
715            else_y,
716        });
717    }
718}
719
720/// Find participants involved in a list of items (returns min/max edges and whether leftmost is included)
721fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
722    let mut min_left: Option<f64> = None;
723    let mut max_right: Option<f64> = None;
724    let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
725    let mut includes_leftmost = false;
726
727    fn update_bounds(
728        participant: &str,
729        state: &RenderState,
730        min_left: &mut Option<f64>,
731        max_right: &mut Option<f64>,
732        includes_leftmost: &mut bool,
733        leftmost_id: &str,
734    ) {
735        let x = state.get_x(participant);
736        if x > 0.0 {
737            let width = state.get_participant_width(participant);
738            let left = x - width / 2.0;
739            let right = x + width / 2.0;
740            *min_left = Some(min_left.map_or(left, |m| m.min(left)));
741            *max_right = Some(max_right.map_or(right, |m| m.max(right)));
742            if participant == leftmost_id {
743                *includes_leftmost = true;
744            }
745        }
746    }
747
748    fn process_items(
749        items: &[Item],
750        state: &RenderState,
751        min_left: &mut Option<f64>,
752        max_right: &mut Option<f64>,
753        includes_leftmost: &mut bool,
754        leftmost_id: &str,
755    ) {
756        for item in items {
757            match item {
758                Item::Message { from, to, .. } => {
759                    update_bounds(
760                        from,
761                        state,
762                        min_left,
763                        max_right,
764                        includes_leftmost,
765                        leftmost_id,
766                    );
767                    update_bounds(
768                        to,
769                        state,
770                        min_left,
771                        max_right,
772                        includes_leftmost,
773                        leftmost_id,
774                    );
775                }
776                Item::Note { participants, .. } => {
777                    for p in participants {
778                        update_bounds(
779                            p,
780                            state,
781                            min_left,
782                            max_right,
783                            includes_leftmost,
784                            leftmost_id,
785                        );
786                    }
787                }
788                Item::Block {
789                    items, else_items, ..
790                } => {
791                    process_items(
792                        items,
793                        state,
794                        min_left,
795                        max_right,
796                        includes_leftmost,
797                        leftmost_id,
798                    );
799                    if let Some(else_items) = else_items {
800                        process_items(
801                            else_items,
802                            state,
803                            min_left,
804                            max_right,
805                            includes_leftmost,
806                            leftmost_id,
807                        );
808                    }
809                }
810                Item::Activate { participant }
811                | Item::Deactivate { participant }
812                | Item::Destroy { participant } => {
813                    update_bounds(
814                        participant,
815                        state,
816                        min_left,
817                        max_right,
818                        includes_leftmost,
819                        leftmost_id,
820                    );
821                }
822                _ => {}
823            }
824        }
825    }
826
827    process_items(
828        items,
829        state,
830        &mut min_left,
831        &mut max_right,
832        &mut includes_leftmost,
833        leftmost_id,
834    );
835
836    match (min_left, max_right) {
837        (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
838        _ => None,
839    }
840}
841
842/// Calculate block x boundaries based on involved participants and label length
843fn calculate_block_bounds_with_label(
844    items: &[Item],
845    else_items: Option<&[Item]>,
846    label: &str,
847    kind: &str,
848    depth: usize,
849    state: &RenderState,
850) -> (f64, f64) {
851    let mut all_items: Vec<&Item> = items.iter().collect();
852    if let Some(else_items) = else_items {
853        all_items.extend(else_items.iter());
854    }
855
856    // Convert Vec<&Item> to slice for find_involved_participants
857    let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
858
859    let (base_x1, base_x2, includes_leftmost) =
860        if let Some((min_left, max_right, includes_leftmost)) =
861            find_involved_participants(&items_slice, state)
862        {
863            let margin = state.config.block_margin;
864            (min_left - margin, max_right + margin, includes_leftmost)
865        } else {
866            // Fallback to full width if no participants found
867            (state.block_left(), state.block_right(), false)
868        };
869
870    // Calculate minimum width needed for label
871    // Pentagon width + gap + condition label width + right margin
872    let pentagon_width = block_tab_width(kind);
873    let label_font_size = state.config.font_size - 1.0;
874    let label_padding_x = 6.0;
875    let condition_width = if label.is_empty() {
876        0.0
877    } else {
878        let condition_text = format!("[{}]", label);
879        let base_width =
880            (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
881        base_width + label_padding_x * 2.0
882    };
883    let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; // Extra right margin
884
885    // Ensure block is wide enough for the label
886    let current_width = base_x2 - base_x1;
887    let (mut x1, mut x2) = if current_width < min_label_width {
888        // Extend the right side to accommodate the label
889        (base_x1, base_x1 + min_label_width)
890    } else {
891        (base_x1, base_x2)
892    };
893
894    // Inset nested blocks so they sit inside their parent with padding.
895    let nested_padding = depth as f64 * 20.0;
896    if nested_padding > 0.0 {
897        let available = x2 - x1;
898        let max_padding = ((available - min_label_width) / 2.0).max(0.0);
899        let inset = nested_padding.min(max_padding);
900        x1 += inset;
901        x2 -= inset;
902    }
903
904    if depth == 0 && includes_leftmost {
905        x1 = x1.min(state.config.padding);
906    }
907
908    (x1, x2)
909}
910
911/// Pre-calculate block backgrounds by doing a dry run
912fn collect_block_backgrounds(
913    state: &mut RenderState,
914    items: &[Item],
915    depth: usize,
916    active_activation_count: &mut usize,
917) {
918    for item in items {
919        match item {
920            Item::Message {
921                text,
922                from,
923                to,
924                arrow,
925                activate,
926                deactivate,
927                create,
928                ..
929            } => {
930                state.apply_else_return_gap(arrow);
931                let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
932                    ACTIVATION_CHAIN_GAP
933                } else {
934                    0.0
935                };
936                let is_self = from == to;
937                let lines: Vec<&str> = text.split("\\n").collect();
938                let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
939
940                if is_self {
941                    let mut spacing = self_message_spacing(&state.config, lines.len());
942                    if state.in_serial_block() {
943                        spacing -= SERIAL_SELF_MESSAGE_ADJUST;
944                    }
945                    if *active_activation_count > 0 {
946                        spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
947                    }
948                    state.current_y += spacing;
949                } else {
950                    let spacing_line_height = message_spacing_line_height(&state.config);
951                    let extra_height = if lines.len() > 1 {
952                        (lines.len() - 1) as f64 * spacing_line_height
953                    } else {
954                        0.0
955                    };
956                    if lines.len() > 1 {
957                        state.current_y += extra_height;
958                    }
959                    state.current_y += state.config.row_height + delay_offset;
960                }
961
962                if *create {
963                    state.current_y += CREATE_MESSAGE_SPACING;
964                }
965
966                state.apply_serial_first_row_gap();
967
968                if *activate && depth == 0 {
969                    state.current_y += ACTIVATION_START_GAP;
970                }
971                if chain_gap > 0.0 {
972                    state.current_y += chain_gap;
973                }
974                if *activate {
975                    *active_activation_count += 1;
976                }
977                if *deactivate && *active_activation_count > 0 {
978                    *active_activation_count -= 1;
979                }
980            }
981            Item::Note { text, .. } => {
982                let lines: Vec<&str> = text.split("\\n").collect();
983                let line_height = note_line_height(&state.config);
984                let note_height =
985                    note_padding(&state.config) * 2.0 + lines.len() as f64 * line_height;
986                state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
987            }
988            Item::State { text, .. } => {
989                let lines: Vec<&str> = text.split("\\n").collect();
990                let line_height = state_line_height(&state.config);
991                let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
992                state.current_y += box_height + item_pre_gap(&state.config) + STATE_EXTRA_GAP;
993            }
994            Item::Ref { text, .. } => {
995                let lines: Vec<&str> = text.split("\\n").collect();
996                let line_height = ref_line_height(&state.config);
997                let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
998                state.current_y += box_height + item_pre_gap(&state.config) + REF_EXTRA_GAP;
999            }
1000            Item::Description { text } => {
1001                let lines: Vec<&str> = text.split("\\n").collect();
1002                let line_height = state.config.font_size + 4.0;
1003                state.current_y += lines.len() as f64 * line_height + 10.0;
1004            }
1005            Item::Destroy { .. } => {
1006                state.current_y += DESTROY_SPACING;
1007            }
1008            Item::Activate { .. } => {
1009                *active_activation_count += 1;
1010            }
1011            Item::Deactivate { .. } => {
1012                if *active_activation_count > 0 {
1013                    *active_activation_count -= 1;
1014                }
1015            }
1016            Item::Block {
1017                kind,
1018                label,
1019                items,
1020                else_items,
1021            } => {
1022                if block_is_parallel(kind) {
1023                    state.push_parallel();
1024                    let start_y = state.current_y;
1025                    let mut max_end_y = start_y;
1026                    let start_activation_count = *active_activation_count;
1027                    for item in items {
1028                        state.current_y = start_y;
1029                        *active_activation_count = start_activation_count;
1030                        collect_block_backgrounds(
1031                            state,
1032                            std::slice::from_ref(item),
1033                            depth,
1034                            active_activation_count,
1035                        );
1036                        if state.current_y > max_end_y {
1037                            max_end_y = state.current_y;
1038                        }
1039                    }
1040                    *active_activation_count = start_activation_count;
1041                    let gap = if parallel_needs_gap(items) {
1042                        PARALLEL_BLOCK_GAP
1043                    } else {
1044                        0.0
1045                    };
1046                    state.current_y = max_end_y + gap;
1047                    state.pop_parallel();
1048                    continue;
1049                }
1050
1051                if matches!(kind, BlockKind::Serial) {
1052                    state.push_serial_first_row_pending();
1053                    collect_block_backgrounds(state, items, depth, active_activation_count);
1054                    if let Some(else_items) = else_items {
1055                        collect_block_backgrounds(
1056                            state,
1057                            else_items,
1058                            depth,
1059                            active_activation_count,
1060                        );
1061                    }
1062                    state.pop_serial_first_row_pending();
1063                    continue;
1064                }
1065
1066                if !block_has_frame(kind) {
1067                    collect_block_backgrounds(state, items, depth, active_activation_count);
1068                    if let Some(else_items) = else_items {
1069                        collect_block_backgrounds(
1070                            state,
1071                            else_items,
1072                            depth,
1073                            active_activation_count,
1074                        );
1075                    }
1076                    continue;
1077                }
1078
1079                let start_y = state.current_y;
1080                let frame_shift = block_frame_shift(depth);
1081                let frame_start_y = start_y - frame_shift;
1082
1083                // Calculate bounds based on involved participants and label width
1084                let (x1, x2) = calculate_block_bounds_with_label(
1085                    items,
1086                    else_items.as_deref(),
1087                    label,
1088                    kind.as_str(),
1089                    depth,
1090                    state,
1091                );
1092
1093                state.current_y += block_header_space(&state.config, depth);
1094                collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1095
1096                let else_y = if else_items.is_some() {
1097                    Some(state.current_y)
1098                } else {
1099                    None
1100                };
1101
1102                if let Some(else_items) = else_items {
1103                    state.push_else_return_pending();
1104                    state.current_y += block_else_spacing(&state.config, depth);
1105                    collect_block_backgrounds(
1106                        state,
1107                        else_items,
1108                        depth + 1,
1109                        active_activation_count,
1110                    );
1111                    state.pop_else_return_pending();
1112                }
1113
1114                let end_y = state.current_y - state.config.row_height
1115                    + block_footer_padding(&state.config, depth);
1116                let frame_end_y = end_y - frame_shift;
1117                state.current_y = end_y + state.config.row_height * 1.0;
1118
1119                // Collect this block's background
1120                state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1121                // Collect this block's label for rendering above activations/lifelines
1122                state.add_block_label(
1123                    x1,
1124                    frame_start_y,
1125                    frame_end_y,
1126                    x2,
1127                    kind.as_str(),
1128                    label,
1129                    else_y,
1130                );
1131            }
1132            _ => {}
1133        }
1134    }
1135}
1136
1137/// Render all collected block backgrounds
1138fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1139    let theme = &state.config.theme;
1140    for bg in &state.block_backgrounds {
1141        writeln!(
1142            svg,
1143            r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1144            x = bg.x,
1145            y = bg.y,
1146            w = bg.width,
1147            h = bg.height,
1148            fill = theme.block_fill
1149        )
1150        .unwrap();
1151    }
1152}
1153
1154/// Render all collected block labels (frame, pentagon, condition text, else divider)
1155/// This is called AFTER activations are drawn so labels appear on top
1156fn render_block_labels(svg: &mut String, state: &RenderState) {
1157    let theme = &state.config.theme;
1158
1159    for bl in &state.block_labels {
1160        let x1 = bl.x1;
1161        let x2 = bl.x2;
1162        let start_y = bl.start_y;
1163        let end_y = bl.end_y;
1164
1165        // Draw block frame
1166        writeln!(
1167            svg,
1168            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1169            x = x1,
1170            y = start_y,
1171            w = x2 - x1,
1172            h = end_y - start_y
1173        )
1174        .unwrap();
1175
1176        // Pentagon/tab-shaped label (WSD style)
1177        let label_text = &bl.kind;
1178        let label_width = block_tab_width(label_text);
1179        let label_height = BLOCK_LABEL_HEIGHT;
1180        let label_text_offset = 16.0;
1181        let notch_size = 5.0;
1182        let label_font_size = state.config.font_size - 1.0;
1183        let label_padding_x = 6.0;
1184
1185        // Pentagon path
1186        let pentagon_path = format!(
1187            "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1188            x1 = x1,
1189            y1 = start_y,
1190            x2 = x1 + label_width,
1191            y2 = start_y + label_height - notch_size,
1192            x3 = x1 + label_width - notch_size,
1193            y3 = start_y + label_height
1194        );
1195
1196        writeln!(
1197            svg,
1198            r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1199            path = pentagon_path,
1200            fill = theme.block_label_fill,
1201            stroke = theme.block_stroke
1202        )
1203        .unwrap();
1204
1205        // Block type label text
1206        writeln!(
1207            svg,
1208            r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1209            x = x1 + 5.0,
1210            y = start_y + label_text_offset,
1211            kind = label_text
1212        )
1213        .unwrap();
1214
1215        // Condition label (outside the pentagon)
1216        if !bl.label.is_empty() {
1217            let condition_text = format!("[{}]", bl.label);
1218            let text_x = x1 + label_width + 8.0;
1219            let text_y = start_y + label_text_offset;
1220            let base_width = (estimate_text_width(&condition_text, label_font_size)
1221                - TEXT_WIDTH_PADDING)
1222                .max(0.0);
1223            let bg_width = base_width + label_padding_x * 2.0;
1224
1225            writeln!(
1226                svg,
1227                r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1228                x = text_x - label_padding_x,
1229                y = start_y,
1230                w = bg_width,
1231                h = label_height,
1232                fill = theme.block_label_fill,
1233                stroke = theme.block_stroke
1234            )
1235            .unwrap();
1236
1237            writeln!(
1238                svg,
1239                r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1240                x = text_x,
1241                y = text_y,
1242                label = escape_xml(&condition_text)
1243            )
1244            .unwrap();
1245        }
1246
1247        // Else separator
1248        if let Some(else_y) = bl.else_y {
1249            writeln!(
1250                svg,
1251                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1252                x1 = x1,
1253                y = else_y,
1254                x2 = x2,
1255                c = theme.block_stroke
1256            )
1257            .unwrap();
1258
1259            let else_text = "[else]";
1260            let else_text_x = x1 + 4.0;
1261            let else_base_width =
1262                (estimate_text_width(else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1263            let else_bg_width = else_base_width + label_padding_x * 2.0;
1264            let else_rect_y = else_y - label_height;
1265            let else_text_y = else_rect_y + label_text_offset;
1266
1267            writeln!(
1268                svg,
1269                r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1270                x = else_text_x - label_padding_x,
1271                y = else_rect_y,
1272                w = else_bg_width,
1273                h = label_height,
1274                fill = theme.block_label_fill,
1275                stroke = theme.block_stroke
1276            )
1277            .unwrap();
1278
1279            writeln!(
1280                svg,
1281                r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1282                x = else_text_x,
1283                y = else_text_y,
1284                label = else_text
1285            )
1286            .unwrap();
1287        }
1288    }
1289}
1290
1291/// Render a diagram to SVG
1292pub fn render(diagram: &Diagram) -> String {
1293    render_with_config(diagram, Config::default())
1294}
1295
1296/// Render a diagram to SVG with custom config
1297pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1298    let participants = diagram.participants();
1299    let has_title = diagram.title.is_some();
1300    let footer_style = diagram.options.footer;
1301    let mut state = RenderState::new(
1302        config,
1303        participants,
1304        &diagram.items,
1305        has_title,
1306        footer_style,
1307    );
1308    let mut svg = String::new();
1309
1310    // Pre-calculate height
1311    let content_height = calculate_height(&diagram.items, &state.config, 0);
1312    let title_space = if has_title {
1313        state.config.title_height
1314    } else {
1315        0.0
1316    };
1317    let footer_space = match footer_style {
1318        FooterStyle::Box => state.config.header_height,
1319        FooterStyle::Bar | FooterStyle::None => 0.0,
1320    };
1321    let footer_label_extra = match footer_style {
1322        FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1323        FooterStyle::Bar | FooterStyle::None => 0.0,
1324    };
1325    let footer_margin = state.config.row_height; // Space between content and footer
1326    let base_total_height = state.config.padding * 2.0
1327        + title_space
1328        + state.config.header_height
1329        + content_height
1330        + footer_margin
1331        + footer_space;
1332    let total_height = base_total_height + footer_label_extra;
1333    let total_width = state.diagram_width();
1334
1335    // SVG header
1336    writeln!(
1337        &mut svg,
1338        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1339        w = total_width,
1340        h = total_height
1341    )
1342    .unwrap();
1343
1344    // Styles based on theme
1345    let theme = &state.config.theme;
1346    let lifeline_dash = match theme.lifeline_style {
1347        LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1348        LifelineStyle::Solid => "",
1349    };
1350
1351    svg.push_str("<defs>\n");
1352    svg.push_str("<style>\n");
1353    writeln!(
1354        &mut svg,
1355        ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1356        fill = theme.participant_fill,
1357        stroke = theme.participant_stroke
1358    )
1359    .unwrap();
1360    writeln!(
1361        &mut svg,
1362        ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1363        f = theme.font_family,
1364        s = state.config.font_size,
1365        c = theme.participant_text
1366    )
1367    .unwrap();
1368    writeln!(
1369        &mut svg,
1370        ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1371        c = theme.lifeline_color,
1372        dash = lifeline_dash
1373    )
1374    .unwrap();
1375    writeln!(
1376        &mut svg,
1377        ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1378        c = theme.message_color
1379    )
1380    .unwrap();
1381    writeln!(
1382        &mut svg,
1383        ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1384        c = theme.message_color
1385    )
1386    .unwrap();
1387    writeln!(
1388        &mut svg,
1389        ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1390        f = theme.font_family,
1391        s = state.config.font_size,
1392        c = theme.message_text_color
1393    )
1394    .unwrap();
1395    writeln!(
1396        &mut svg,
1397        ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1398        fill = theme.note_fill,
1399        stroke = theme.note_stroke
1400    )
1401    .unwrap();
1402    writeln!(
1403        &mut svg,
1404        ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1405        f = theme.font_family,
1406        s = state.config.font_size - 1.0,
1407        c = theme.note_text_color
1408    )
1409    .unwrap();
1410    writeln!(
1411        &mut svg,
1412        ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1413        c = theme.block_stroke
1414    )
1415    .unwrap();
1416    writeln!(
1417        &mut svg,
1418        ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1419        f = theme.font_family,
1420        s = state.config.font_size - 1.0,
1421        c = theme.message_text_color
1422    )
1423    .unwrap();
1424    writeln!(
1425        &mut svg,
1426        ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1427        fill = theme.activation_fill,
1428        stroke = theme.activation_stroke
1429    )
1430    .unwrap();
1431    writeln!(
1432        &mut svg,
1433        ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1434        fill = theme.actor_fill,
1435        stroke = theme.actor_stroke
1436    )
1437    .unwrap();
1438    writeln!(
1439        &mut svg,
1440        ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1441        c = theme.actor_stroke
1442    )
1443    .unwrap();
1444    writeln!(
1445        &mut svg,
1446        ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1447        f = theme.font_family,
1448        s = state.config.font_size + 4.0,
1449        c = theme.message_text_color
1450    )
1451    .unwrap();
1452    // Arrowhead styles
1453    writeln!(
1454        &mut svg,
1455        ".arrowhead {{ fill: {c}; stroke: none; }}",
1456        c = theme.message_color
1457    )
1458    .unwrap();
1459    writeln!(
1460        &mut svg,
1461        ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1462        c = theme.message_color
1463    )
1464    .unwrap();
1465    svg.push_str("</style>\n");
1466    svg.push_str("</defs>\n");
1467
1468    // Background with theme color
1469    writeln!(
1470        &mut svg,
1471        r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1472        bg = theme.background
1473    )
1474    .unwrap();
1475
1476    // Title
1477    if let Some(title) = &diagram.title {
1478        let title_y = state.config.padding + state.config.font_size + 10.0;
1479        writeln!(
1480            &mut svg,
1481            r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1482            x = total_width / 2.0,
1483            y = title_y,
1484            t = escape_xml(title)
1485        )
1486        .unwrap();
1487    }
1488
1489    // Calculate footer position
1490    let header_y = state.header_top();
1491    let footer_y = match footer_style {
1492        FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1493        FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1494    };
1495
1496    // Pre-calculate block backgrounds (dry run)
1497    state.current_y = state.content_start();
1498    let mut active_activation_count = 0;
1499    collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1500
1501    // Draw block backgrounds FIRST (behind lifelines)
1502    render_block_backgrounds(&mut svg, &state);
1503
1504    // Reset current_y for actual rendering
1505    state.current_y = state.content_start();
1506
1507    // Draw lifelines (behind messages but above block backgrounds)
1508    let lifeline_start = header_y + state.config.header_height;
1509    let lifeline_end = footer_y;
1510
1511    for p in &state.participants {
1512        let x = state.get_x(p.id());
1513        writeln!(
1514            &mut svg,
1515            r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1516            x = x,
1517            y1 = lifeline_start,
1518            y2 = lifeline_end
1519        )
1520        .unwrap();
1521    }
1522
1523    // Draw participant headers
1524    render_participant_headers(&mut svg, &state, header_y);
1525
1526    // Render items
1527    state.current_y = state.content_start();
1528    render_items(&mut svg, &mut state, &diagram.items, 0);
1529
1530    // Draw activation bars
1531    render_activations(&mut svg, &mut state, footer_y);
1532
1533    // Draw block labels AFTER activations so they appear on top
1534    render_block_labels(&mut svg, &state);
1535
1536    // Draw participant footers based on footer style option
1537    match state.footer_style {
1538        FooterStyle::Box => {
1539            render_participant_headers(&mut svg, &state, footer_y);
1540        }
1541        FooterStyle::Bar => {
1542            // Draw simple horizontal line across all participants
1543            let left = state.leftmost_x()
1544                - state.get_participant_width(
1545                    state.participants.first().map(|p| p.id()).unwrap_or(""),
1546                ) / 2.0;
1547            let right = state.rightmost_x()
1548                + state
1549                    .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1550                    / 2.0;
1551            writeln!(
1552                &mut svg,
1553                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1554                x1 = left,
1555                y = footer_y,
1556                x2 = right,
1557                c = state.config.theme.lifeline_color
1558            )
1559            .unwrap();
1560        }
1561        FooterStyle::None => {
1562            // No footer at all
1563        }
1564    }
1565
1566    svg.push_str("</svg>\n");
1567    svg
1568}
1569
1570fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1571    fn inner(
1572        items: &[Item],
1573        config: &Config,
1574        depth: usize,
1575        else_pending: &mut Vec<bool>,
1576        serial_pending: &mut Vec<bool>,
1577        active_activation_count: &mut usize,
1578        parallel_depth: &mut usize,
1579    ) -> f64 {
1580        let mut height = 0.0;
1581        let line_height = config.font_size + 4.0;
1582        for item in items {
1583            match item {
1584                Item::Message {
1585                    from,
1586                    to,
1587                    text,
1588                    arrow,
1589                    create,
1590                    activate,
1591                    deactivate,
1592                    ..
1593                } => {
1594                    if let Some(pending) = else_pending.last_mut() {
1595                        if *pending && matches!(arrow.line, LineStyle::Dashed) {
1596                            height += ELSE_RETURN_GAP;
1597                            *pending = false;
1598                        }
1599                    }
1600                    let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1601                        ACTIVATION_CHAIN_GAP
1602                    } else {
1603                        0.0
1604                    };
1605                    let is_self = from == to;
1606                    let lines = text.split("\\n").count();
1607                    let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1608                    if is_self {
1609                        let mut spacing = self_message_spacing(config, lines);
1610                        if !serial_pending.is_empty() {
1611                            spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1612                        }
1613                        if *active_activation_count > 0 {
1614                            spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1615                        }
1616                        height += spacing;
1617                    } else {
1618                        let spacing_line_height = message_spacing_line_height(config);
1619                        height += config.row_height
1620                            + (lines.saturating_sub(1)) as f64 * spacing_line_height
1621                            + delay_offset;
1622                    }
1623                    if *create {
1624                        height += CREATE_MESSAGE_SPACING;
1625                    }
1626                    if let Some(pending) = serial_pending.last_mut() {
1627                        if *pending {
1628                            height += serial_first_row_gap(*parallel_depth);
1629                            *pending = false;
1630                        }
1631                    }
1632                    if *activate && depth == 0 {
1633                        height += ACTIVATION_START_GAP;
1634                    }
1635                    height += chain_gap;
1636                    if *activate {
1637                        *active_activation_count += 1;
1638                    }
1639                    if *deactivate && *active_activation_count > 0 {
1640                        *active_activation_count -= 1;
1641                    }
1642                }
1643                Item::Note { text, .. } => {
1644                    let lines = text.split("\\n").count();
1645                    let note_height =
1646                        note_padding(config) * 2.0 + lines as f64 * note_line_height(config);
1647                    height += note_height.max(config.row_height) + NOTE_MARGIN;
1648                }
1649                Item::State { text, .. } => {
1650                    let lines = text.split("\\n").count();
1651                    let box_height =
1652                        config.note_padding * 2.0 + lines as f64 * state_line_height(config);
1653                    height += box_height + item_pre_gap(config) + STATE_EXTRA_GAP;
1654                }
1655                Item::Ref { text, .. } => {
1656                    let lines = text.split("\\n").count();
1657                    let box_height =
1658                        config.note_padding * 2.0 + lines as f64 * ref_line_height(config);
1659                    height += box_height + item_pre_gap(config) + REF_EXTRA_GAP;
1660                }
1661                Item::Description { text } => {
1662                    let lines = text.split("\\n").count();
1663                    height += lines as f64 * line_height + 10.0;
1664                }
1665                Item::Block {
1666                    kind,
1667                    items,
1668                    else_items,
1669                    ..
1670                } => {
1671                    if block_is_parallel(kind) {
1672                        let mut max_branch_height = 0.0;
1673                        let base_activation_count = *active_activation_count;
1674                        *parallel_depth += 1;
1675                        for item in items {
1676                            *active_activation_count = base_activation_count;
1677                            let branch_height = inner(
1678                                std::slice::from_ref(item),
1679                                config,
1680                                depth,
1681                                else_pending,
1682                                serial_pending,
1683                                active_activation_count,
1684                                parallel_depth,
1685                            );
1686                            if branch_height > max_branch_height {
1687                                max_branch_height = branch_height;
1688                            }
1689                        }
1690                        *active_activation_count = base_activation_count;
1691                        if *parallel_depth > 0 {
1692                            *parallel_depth -= 1;
1693                        }
1694                        let gap = if parallel_needs_gap(items) {
1695                            PARALLEL_BLOCK_GAP
1696                        } else {
1697                            0.0
1698                        };
1699                        height += max_branch_height + gap;
1700                        continue;
1701                    }
1702
1703                    if matches!(kind, BlockKind::Serial) {
1704                        serial_pending.push(true);
1705                        height += inner(
1706                            items,
1707                            config,
1708                            depth,
1709                            else_pending,
1710                            serial_pending,
1711                            active_activation_count,
1712                            parallel_depth,
1713                        );
1714                        if let Some(else_items) = else_items {
1715                            height += inner(
1716                                else_items,
1717                                config,
1718                                depth,
1719                                else_pending,
1720                                serial_pending,
1721                                active_activation_count,
1722                                parallel_depth,
1723                            );
1724                        }
1725                        serial_pending.pop();
1726                    } else if !block_has_frame(kind) {
1727                        height += inner(
1728                            items,
1729                            config,
1730                            depth,
1731                            else_pending,
1732                            serial_pending,
1733                            active_activation_count,
1734                            parallel_depth,
1735                        );
1736                        if let Some(else_items) = else_items {
1737                            height += inner(
1738                                else_items,
1739                                config,
1740                                depth,
1741                                else_pending,
1742                                serial_pending,
1743                                active_activation_count,
1744                                parallel_depth,
1745                            );
1746                        }
1747                    } else {
1748                        height += block_header_space(config, depth);
1749                        height += inner(
1750                            items,
1751                            config,
1752                            depth + 1,
1753                            else_pending,
1754                            serial_pending,
1755                            active_activation_count,
1756                            parallel_depth,
1757                        );
1758                        if let Some(else_items) = else_items {
1759                            else_pending.push(true);
1760                            height += block_else_spacing(config, depth);
1761                            height += inner(
1762                                else_items,
1763                                config,
1764                                depth + 1,
1765                                else_pending,
1766                                serial_pending,
1767                                active_activation_count,
1768                                parallel_depth,
1769                            );
1770                            else_pending.pop();
1771                        }
1772                        height += block_footer_padding(config, depth) + config.row_height * 1.0
1773                            - config.row_height;
1774                    }
1775                }
1776                Item::Activate { .. } => {
1777                    *active_activation_count += 1;
1778                }
1779                Item::Deactivate { .. } => {
1780                    if *active_activation_count > 0 {
1781                        *active_activation_count -= 1;
1782                    }
1783                }
1784                Item::Destroy { .. } => {
1785                    height += DESTROY_SPACING;
1786                }
1787                Item::ParticipantDecl { .. } => {}
1788                Item::Autonumber { .. } => {}
1789                Item::DiagramOption { .. } => {} // Options don't take space
1790            }
1791        }
1792        height
1793    }
1794
1795    let mut else_pending = Vec::new();
1796    let mut serial_pending = Vec::new();
1797    let mut active_activation_count = 0;
1798    let mut parallel_depth = 0;
1799    inner(
1800        items,
1801        config,
1802        depth,
1803        &mut else_pending,
1804        &mut serial_pending,
1805        &mut active_activation_count,
1806        &mut parallel_depth,
1807    )
1808}
1809
1810fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
1811    let shape = state.config.theme.participant_shape;
1812
1813    for p in &state.participants {
1814        let x = state.get_x(p.id());
1815        let p_width = state.get_participant_width(p.id());
1816        let box_x = x - p_width / 2.0;
1817
1818        match p.kind {
1819            ParticipantKind::Participant => {
1820                // Draw shape based on theme
1821                match shape {
1822                    ParticipantShape::Rectangle => {
1823                        writeln!(
1824                            svg,
1825                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
1826                            x = box_x,
1827                            y = y,
1828                            w = p_width,
1829                            h = state.config.header_height
1830                        )
1831                        .unwrap();
1832                    }
1833                    ParticipantShape::RoundedRect => {
1834                        writeln!(
1835                            svg,
1836                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
1837                            x = box_x,
1838                            y = y,
1839                            w = p_width,
1840                            h = state.config.header_height
1841                        )
1842                        .unwrap();
1843                    }
1844                    ParticipantShape::Circle => {
1845                        // Draw ellipse/circle that fits in the header area
1846                        let rx = p_width / 2.0 - 5.0;
1847                        let ry = state.config.header_height / 2.0 - 2.0;
1848                        writeln!(
1849                            svg,
1850                            r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
1851                            cx = x,
1852                            cy = y + state.config.header_height / 2.0,
1853                            rx = rx,
1854                            ry = ry
1855                        )
1856                        .unwrap();
1857                    }
1858                }
1859                // Name centered in box (handle multiline with \n)
1860                let lines: Vec<&str> = p.name.split("\\n").collect();
1861                if lines.len() == 1 {
1862                    writeln!(
1863                        svg,
1864                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
1865                        x = x,
1866                        y = y + state.config.header_height / 2.0 + 5.0,
1867                        name = escape_xml(&p.name)
1868                    )
1869                    .unwrap();
1870                } else {
1871                    let line_height = state.config.font_size + 2.0;
1872                    let total_height = lines.len() as f64 * line_height;
1873                    let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
1874                        + line_height * 0.8;
1875                    write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
1876                    for (i, line) in lines.iter().enumerate() {
1877                        let dy = if i == 0 { start_y } else { line_height };
1878                        if i == 0 {
1879                            writeln!(
1880                                svg,
1881                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
1882                                x = x,
1883                                y = dy,
1884                                text = escape_xml(line)
1885                            )
1886                            .unwrap();
1887                        } else {
1888                            writeln!(
1889                                svg,
1890                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
1891                                x = x,
1892                                dy = dy,
1893                                text = escape_xml(line)
1894                            )
1895                            .unwrap();
1896                        }
1897                    }
1898                    writeln!(svg, "</text>").unwrap();
1899                }
1900            }
1901            ParticipantKind::Actor => {
1902                // Stick figure at top of header area, name below within header
1903                let head_r = 8.0;
1904                let body_len = 12.0;
1905                let arm_len = 10.0;
1906                let leg_len = 10.0;
1907                let figure_height = 38.0; // head(16) + body(12) + legs(10)
1908
1909                // Position figure at top with small margin
1910                let fig_top = y + 8.0;
1911                let fig_center_y = fig_top + head_r + body_len / 2.0;
1912                let arm_y = fig_center_y + 2.0;
1913
1914                // Head
1915                writeln!(
1916                    svg,
1917                    r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
1918                    x = x,
1919                    cy = fig_center_y - body_len / 2.0 - head_r,
1920                    r = head_r
1921                )
1922                .unwrap();
1923                // Body
1924                writeln!(
1925                    svg,
1926                    r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
1927                    x = x,
1928                    y1 = fig_center_y - body_len / 2.0,
1929                    y2 = fig_center_y + body_len / 2.0
1930                )
1931                .unwrap();
1932                // Arms
1933                writeln!(
1934                    svg,
1935                    r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
1936                    x1 = x - arm_len,
1937                    y = arm_y,
1938                    x2 = x + arm_len
1939                )
1940                .unwrap();
1941                // Left leg
1942                writeln!(
1943                    svg,
1944                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
1945                    x = x,
1946                    y1 = fig_center_y + body_len / 2.0,
1947                    x2 = x - leg_len * 0.6,
1948                    y2 = fig_center_y + body_len / 2.0 + leg_len
1949                )
1950                .unwrap();
1951                // Right leg
1952                writeln!(
1953                    svg,
1954                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
1955                    x = x,
1956                    y1 = fig_center_y + body_len / 2.0,
1957                    x2 = x + leg_len * 0.6,
1958                    y2 = fig_center_y + body_len / 2.0 + leg_len
1959                )
1960                .unwrap();
1961                // Name below figure (within header)
1962                let name_lines: Vec<&str> = p.name.split("\\n").collect();
1963                let name_start_y = fig_top + figure_height + 5.0;
1964                if name_lines.len() == 1 {
1965                    writeln!(
1966                        svg,
1967                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
1968                        x = x,
1969                        y = name_start_y + state.config.font_size,
1970                        name = escape_xml(&p.name)
1971                    )
1972                    .unwrap();
1973                } else {
1974                    // Multiline actor name using tspan
1975                    let line_height = state.config.font_size + 2.0;
1976                    writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
1977                    for (i, line) in name_lines.iter().enumerate() {
1978                        if i == 0 {
1979                            writeln!(
1980                                svg,
1981                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
1982                                x = x,
1983                                y = name_start_y + state.config.font_size,
1984                                text = escape_xml(line)
1985                            )
1986                            .unwrap();
1987                        } else {
1988                            writeln!(
1989                                svg,
1990                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
1991                                x = x,
1992                                dy = line_height,
1993                                text = escape_xml(line)
1994                            )
1995                            .unwrap();
1996                        }
1997                    }
1998                    writeln!(svg, "</text>").unwrap();
1999                }
2000            }
2001        }
2002    }
2003}
2004
2005fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2006    for item in items {
2007        match item {
2008            Item::Message {
2009                from,
2010                to,
2011                text,
2012                arrow,
2013                activate,
2014                deactivate,
2015                create,
2016                ..
2017            } => {
2018                render_message(
2019                    svg,
2020                    state,
2021                    from,
2022                    to,
2023                    text,
2024                    arrow,
2025                    *activate,
2026                    *deactivate,
2027                    *create,
2028                    depth,
2029                );
2030            }
2031            Item::Note {
2032                position,
2033                participants,
2034                text,
2035            } => {
2036                render_note(svg, state, position, participants, text);
2037            }
2038            Item::Block {
2039                kind,
2040                label,
2041                items,
2042                else_items,
2043            } => {
2044                render_block(svg, state, kind, label, items, else_items.as_deref(), depth);
2045            }
2046            Item::Activate { participant } => {
2047                let y = state.current_y;
2048                state
2049                    .activations
2050                    .entry(participant.clone())
2051                    .or_default()
2052                    .push((y, None));
2053            }
2054            Item::Deactivate { participant } => {
2055                if let Some(acts) = state.activations.get_mut(participant) {
2056                    if let Some(act) = acts.last_mut() {
2057                        if act.1.is_none() {
2058                            act.1 = Some(state.current_y);
2059                        }
2060                    }
2061                }
2062            }
2063            Item::Destroy { participant } => {
2064                state.destroyed.insert(participant.clone(), state.current_y);
2065                // Draw X mark on the lifeline
2066                let x = state.get_x(participant);
2067                let y = state.current_y;
2068                let size = 12.0;
2069                let theme = &state.config.theme;
2070                writeln!(
2071                    svg,
2072                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2073                    x1 = x - size,
2074                    y1 = y - size,
2075                    x2 = x + size,
2076                    y2 = y + size,
2077                    stroke = theme.message_color
2078                )
2079                .unwrap();
2080                writeln!(
2081                    svg,
2082                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2083                    x1 = x + size,
2084                    y1 = y - size,
2085                    x2 = x - size,
2086                    y2 = y + size,
2087                    stroke = theme.message_color
2088                )
2089                .unwrap();
2090                state.current_y += DESTROY_SPACING;
2091            }
2092            Item::Autonumber { enabled, start } => {
2093                if *enabled {
2094                    state.autonumber = Some(start.unwrap_or(1));
2095                } else {
2096                    state.autonumber = None;
2097                }
2098            }
2099            Item::ParticipantDecl { .. } => {
2100                // Already processed
2101            }
2102            Item::State { participants, text } => {
2103                render_state(svg, state, participants, text);
2104            }
2105            Item::Ref {
2106                participants,
2107                text,
2108                input_from,
2109                input_label,
2110                output_to,
2111                output_label,
2112            } => {
2113                render_ref(
2114                    svg,
2115                    state,
2116                    participants,
2117                    text,
2118                    input_from.as_deref(),
2119                    input_label.as_deref(),
2120                    output_to.as_deref(),
2121                    output_label.as_deref(),
2122                );
2123            }
2124            Item::DiagramOption { .. } => {
2125                // Options are processed at render start, not during item rendering
2126            }
2127            Item::Description { text } => {
2128                render_description(svg, state, text);
2129            }
2130        }
2131    }
2132}
2133
2134fn render_message(
2135    svg: &mut String,
2136    state: &mut RenderState,
2137    from: &str,
2138    to: &str,
2139    text: &str,
2140    arrow: &Arrow,
2141    activate: bool,
2142    deactivate: bool,
2143    create: bool,
2144    depth: usize,
2145) {
2146    let x1 = state.get_x(from);
2147    let x2 = state.get_x(to);
2148
2149    state.apply_else_return_gap(arrow);
2150    let active_count = state.active_activation_count();
2151    let chain_gap = if activate && depth == 0 && active_count == 1 {
2152        ACTIVATION_CHAIN_GAP
2153    } else {
2154        0.0
2155    };
2156
2157    let is_self = from == to;
2158    let line_class = match arrow.line {
2159        LineStyle::Solid => "message",
2160        LineStyle::Dashed => "message-dashed",
2161    };
2162    let is_filled = matches!(arrow.head, ArrowHead::Filled);
2163
2164    // Get autonumber prefix
2165    let num_prefix = state
2166        .next_number()
2167        .map(|n| format!("{}. ", n))
2168        .unwrap_or_default();
2169
2170    // Calculate text lines and height
2171    let display_text = format!("{}{}", num_prefix, text);
2172    let lines: Vec<&str> = display_text.split("\\n").collect();
2173    let line_height = state.config.font_size + 4.0;
2174    let extra_height = if !is_self && lines.len() > 1 {
2175        let spacing_line_height = message_spacing_line_height(&state.config);
2176        (lines.len() - 1) as f64 * spacing_line_height
2177    } else {
2178        0.0
2179    };
2180
2181    // Add space BEFORE the message for multiline text (text is rendered above arrow)
2182    if !is_self && lines.len() > 1 {
2183        state.current_y += extra_height;
2184    }
2185
2186    let y = state.current_y;
2187    let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2188
2189    // Open message group
2190    writeln!(svg, r#"<g class="message">"#).unwrap();
2191
2192    if is_self {
2193        // Self message - loop back
2194        let loop_width = 40.0;
2195        let text_block_height = lines.len() as f64 * line_height;
2196        let loop_height = (text_block_height + 10.0).max(25.0);
2197        let arrow_end_x = x1;
2198        let arrow_end_y = y + loop_height;
2199        // Arrowhead points left (PI radians)
2200        let direction = std::f64::consts::PI;
2201        let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2202
2203        writeln!(
2204            svg,
2205            r#"  <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2206            x1 = x1,
2207            y = y,
2208            x2 = x1 + loop_width,
2209            y2 = y + loop_height,
2210            arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2211            cls = line_class
2212        )
2213        .unwrap();
2214
2215        // Draw arrowhead as polygon or polyline
2216        if is_filled {
2217            writeln!(
2218                svg,
2219                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2220                points = arrow_points
2221            )
2222            .unwrap();
2223        } else {
2224            writeln!(
2225                svg,
2226                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2227                points = arrow_points
2228            )
2229            .unwrap();
2230        }
2231
2232        // Text - multiline support
2233        let text_x = x1 + loop_width + 5.0;
2234        let max_width = lines
2235            .iter()
2236            .map(|line| estimate_message_width(line, state.config.font_size))
2237            .fold(0.0, f64::max);
2238        let top_line_y = y + 4.0 + 0.5 * line_height;
2239        let bottom_line_y = y + 4.0 + (lines.len() as f64 - 0.5) * line_height;
2240        let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2241        let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2242        let label_x_min = text_x;
2243        let label_x_max = text_x + max_width;
2244        let label_offset = if has_label_text {
2245            let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2246            state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2247        } else {
2248            0.0
2249        };
2250        for (i, line) in lines.iter().enumerate() {
2251            let line_y = y + 4.0 + (i as f64 + 0.5) * line_height + label_offset;
2252            writeln!(
2253                svg,
2254                r#"  <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2255                x = text_x,
2256                y = line_y,
2257                t = escape_xml(line)
2258            )
2259            .unwrap();
2260        }
2261
2262        // Close message group
2263        writeln!(svg, r#"</g>"#).unwrap();
2264
2265        let mut spacing = self_message_spacing(&state.config, lines.len());
2266        if state.in_serial_block() {
2267            spacing -= SERIAL_SELF_MESSAGE_ADJUST;
2268        }
2269        if active_count > 0 {
2270            spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
2271        }
2272        state.current_y += spacing;
2273    } else {
2274        // Regular message - check for delay
2275        let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2276        let y2 = y + delay_offset;
2277
2278        let text_x = (x1 + x2) / 2.0;
2279        let text_y = (y + y2) / 2.0 - 8.0;
2280
2281        // Calculate arrowhead direction and shorten line to not overlap with arrowhead
2282        let direction = arrow_direction(x1, y, x2, y2);
2283        let arrow_points = arrowhead_points(x2, y2, direction);
2284
2285        // Shorten the line so it doesn't overlap with the arrowhead
2286        let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2287        let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2288
2289        // Draw arrow line (slanted if delay)
2290        writeln!(
2291            svg,
2292            r#"  <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2293            x1 = x1,
2294            y1 = y,
2295            lx2 = line_end_x,
2296            ly2 = line_end_y,
2297            cls = line_class
2298        )
2299        .unwrap();
2300
2301        // Draw arrowhead as polygon or polyline
2302        if is_filled {
2303            writeln!(
2304                svg,
2305                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2306                points = arrow_points
2307            )
2308            .unwrap();
2309        } else {
2310            writeln!(
2311                svg,
2312                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2313                points = arrow_points
2314            )
2315            .unwrap();
2316        }
2317
2318        // Text with multiline support (positioned at midpoint of slanted line)
2319        let max_width = lines
2320            .iter()
2321            .map(|line| estimate_message_width(line, state.config.font_size))
2322            .fold(0.0, f64::max);
2323        let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2324        let bottom_line_y = text_y;
2325        let label_offset = if has_label_text {
2326            let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2327            let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2328            let label_x_min = text_x - max_width / 2.0;
2329            let label_x_max = text_x + max_width / 2.0;
2330            let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2331            state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2332        } else {
2333            0.0
2334        };
2335        for (i, line) in lines.iter().enumerate() {
2336            let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2337            writeln!(
2338                svg,
2339                r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2340                x = text_x,
2341                y = line_y,
2342                t = escape_xml(line)
2343            )
2344            .unwrap();
2345        }
2346
2347        // Close message group
2348        writeln!(svg, r#"</g>"#).unwrap();
2349
2350        // Add row_height plus delay offset
2351        state.current_y += state.config.row_height + delay_offset;
2352    }
2353
2354    if create {
2355        state.current_y += CREATE_MESSAGE_SPACING;
2356    }
2357
2358    state.apply_serial_first_row_gap();
2359
2360    if activate && depth == 0 {
2361        state.current_y += ACTIVATION_START_GAP;
2362    }
2363    if chain_gap > 0.0 {
2364        state.current_y += chain_gap;
2365    }
2366
2367    // Handle activation
2368    if activate {
2369        state
2370            .activations
2371            .entry(to.to_string())
2372            .or_default()
2373            .push((y, None));
2374    }
2375    if deactivate {
2376        if let Some(acts) = state.activations.get_mut(from) {
2377            if let Some(act) = acts.last_mut() {
2378                if act.1.is_none() {
2379                    act.1 = Some(y);
2380                }
2381            }
2382        }
2383    }
2384}
2385
2386fn render_note(
2387    svg: &mut String,
2388    state: &mut RenderState,
2389    position: &NotePosition,
2390    participants: &[String],
2391    text: &str,
2392) {
2393    let lines: Vec<&str> = text.split("\\n").collect();
2394    let line_height = note_line_height(&state.config);
2395    let padding = note_padding(&state.config);
2396    let note_height = padding * 2.0 + lines.len() as f64 * line_height;
2397
2398    // Calculate note width based on content or participant span
2399    // Use 12.0 per char for CJK text estimation (wider than ASCII)
2400    let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(10);
2401    let content_width = (max_line_len as f64 * 10.0 + padding * 2.0).max(80.0);
2402
2403    let (x, note_width, text_anchor) = match position {
2404        NotePosition::Left => {
2405            let px = state.get_x(&participants[0]);
2406            let p_width = state.get_participant_width(&participants[0]);
2407            let w = content_width.min(300.0);
2408            // Clamp to not go off left edge
2409            let x = (px - p_width / 2.0 - w - 10.0).max(state.config.padding);
2410            (x, w, "start")
2411        }
2412        NotePosition::Right => {
2413            let px = state.get_x(&participants[0]);
2414            let p_width = state.get_participant_width(&participants[0]);
2415            let w = content_width.min(300.0);
2416            (px + p_width / 2.0 + 10.0, w, "start")
2417        }
2418        NotePosition::Over => {
2419            if participants.len() == 1 {
2420                let px = state.get_x(&participants[0]);
2421                // Allow wider notes for single participant
2422                let w = content_width;
2423                // Clamp to stay within diagram
2424                let x = (px - w / 2.0).max(state.config.padding);
2425                (x, w, "middle")
2426            } else {
2427                // Span across multiple participants
2428                let x1 = state.get_x(&participants[0]);
2429                let x2 = state.get_x(participants.last().unwrap());
2430                let p1_width = state.get_participant_width(&participants[0]);
2431                let p2_width = state.get_participant_width(participants.last().unwrap());
2432                let span_width = (x2 - x1).abs() + (p1_width + p2_width) / 2.0 * 0.8;
2433                let w = span_width.max(content_width);
2434                let center = (x1 + x2) / 2.0;
2435                // Clamp x to stay within padding
2436                let x = (center - w / 2.0).max(state.config.padding);
2437                (x, w, "middle")
2438            }
2439        }
2440    };
2441
2442    let y = state.current_y;
2443    let fold_size = 8.0; // Size of the dog-ear fold
2444
2445    // Note background with dog-ear (folded corner) effect
2446    // Path: start at top-left, go right (leaving space for fold), diagonal fold, down, left, up
2447    let note_path = format!(
2448        "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2449        x = x,
2450        y = y,
2451        x2 = x + note_width - fold_size,
2452        x3 = x + note_width,
2453        y2 = y + fold_size,
2454        y3 = y + note_height
2455    );
2456
2457    writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2458
2459    // Draw the fold triangle (represents the folded corner)
2460    let theme = &state.config.theme;
2461    // Triangle: from fold start, to diagonal corner, to bottom of fold
2462    let fold_path = format!(
2463        "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2464        x1 = x + note_width - fold_size,
2465        y1 = y,
2466        x2 = x + note_width,
2467        y2 = y + fold_size
2468    );
2469
2470    writeln!(
2471        svg,
2472        r##"<path d="{path}" fill="#e0e0a0" stroke="{stroke}" stroke-width="1"/>"##,
2473        path = fold_path,
2474        stroke = theme.note_stroke
2475    )
2476    .unwrap();
2477
2478    // Note text
2479    let text_x = match text_anchor {
2480        "middle" => x + note_width / 2.0,
2481        "start" => x + padding,
2482        _ => x + note_width - padding,
2483    };
2484
2485    for (i, line) in lines.iter().enumerate() {
2486        let text_y = y + padding + (i as f64 + 0.8) * line_height;
2487        writeln!(
2488            svg,
2489            r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2490            x = text_x,
2491            y = text_y,
2492            anchor = if *position == NotePosition::Over {
2493                "middle"
2494            } else {
2495                "start"
2496            },
2497            t = escape_xml(line)
2498        )
2499        .unwrap();
2500    }
2501
2502    // Add note height plus margin
2503    state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
2504}
2505
2506/// Render a state box (rounded rectangle)
2507fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2508    let theme = &state.config.theme;
2509    let lines: Vec<&str> = text.split("\\n").collect();
2510    let line_height = state_line_height(&state.config);
2511    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2512
2513    // Calculate box position and width
2514    let (x, box_width) = if participants.len() == 1 {
2515        let px = state.get_x(&participants[0]);
2516        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2517        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2518        (px - w / 2.0, w)
2519    } else {
2520        let x1 = state.get_x(&participants[0]);
2521        let x2 = state.get_x(participants.last().unwrap());
2522        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2523        let center = (x1 + x2) / 2.0;
2524        (center - span_width / 2.0, span_width)
2525    };
2526
2527    let shift = item_pre_shift(&state.config);
2528    let y = (state.current_y - shift).max(state.content_start());
2529
2530    // Draw rounded rectangle
2531    writeln!(
2532        svg,
2533        r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2534        x = x,
2535        y = y,
2536        w = box_width,
2537        h = box_height,
2538        fill = theme.state_fill,
2539        stroke = theme.state_stroke
2540    )
2541    .unwrap();
2542
2543    // Draw text
2544    let text_x = x + box_width / 2.0;
2545    for (i, line) in lines.iter().enumerate() {
2546        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2547        writeln!(
2548            svg,
2549            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2550            x = text_x,
2551            y = text_y,
2552            fill = theme.state_text_color,
2553            font = theme.font_family,
2554            size = state.config.font_size,
2555            t = escape_xml(line)
2556        )
2557        .unwrap();
2558    }
2559
2560    state.current_y = y + box_height + state.config.row_height + REF_EXTRA_GAP;
2561}
2562
2563/// Render a ref box (hexagon-like shape)
2564fn render_ref(
2565    svg: &mut String,
2566    state: &mut RenderState,
2567    participants: &[String],
2568    text: &str,
2569    input_from: Option<&str>,
2570    input_label: Option<&str>,
2571    output_to: Option<&str>,
2572    output_label: Option<&str>,
2573) {
2574    let theme = &state.config.theme;
2575    let lines: Vec<&str> = text.split("\\n").collect();
2576    let line_height = ref_line_height(&state.config);
2577    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2578    let notch_size = 10.0;
2579
2580    // Calculate box position and width
2581    let (x, box_width) = if participants.len() == 1 {
2582        let px = state.get_x(&participants[0]);
2583        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2584        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2585            .max(100.0);
2586        (px - w / 2.0, w)
2587    } else {
2588        let x1 = state.get_x(&participants[0]);
2589        let x2 = state.get_x(participants.last().unwrap());
2590        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2591        let center = (x1 + x2) / 2.0;
2592        (center - span_width / 2.0, span_width)
2593    };
2594
2595    let shift = item_pre_shift(&state.config);
2596    let y = (state.current_y - shift).max(state.content_start());
2597    let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2598    let output_padding = state.config.note_padding + 3.0;
2599
2600    // Draw input signal arrow if present
2601    if let Some(from) = input_from {
2602        let from_x = state.get_x(from);
2603        let to_x = x; // Left edge of ref box
2604        let arrow_y = y + input_offset;
2605
2606        // Calculate arrowhead
2607        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2608        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2609        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2610
2611        // Draw arrow line
2612        writeln!(
2613            svg,
2614            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2615            x1 = from_x,
2616            y = arrow_y,
2617            x2 = line_end_x
2618        )
2619        .unwrap();
2620
2621        // Draw arrowhead
2622        writeln!(
2623            svg,
2624            r#"<polygon points="{points}" class="arrowhead"/>"#,
2625            points = arrow_points
2626        )
2627        .unwrap();
2628
2629        // Draw label if present
2630        if let Some(label) = input_label {
2631            let text_x = (from_x + to_x) / 2.0;
2632            writeln!(
2633                svg,
2634                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2635                x = text_x,
2636                y = arrow_y - 8.0,
2637                t = escape_xml(label)
2638            )
2639            .unwrap();
2640        }
2641    }
2642
2643    // Draw hexagon-like shape (ref box in WSD style)
2644    // Left side has a notch cut
2645    let ref_path = format!(
2646        "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2647        x1 = x + notch_size,
2648        y1 = y,
2649        x2 = x + box_width,
2650        y2 = y + box_height,
2651        x3 = x,
2652        y3 = y + box_height / 2.0
2653    );
2654
2655    writeln!(
2656        svg,
2657        r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2658        path = ref_path,
2659        fill = theme.ref_fill,
2660        stroke = theme.ref_stroke
2661    )
2662    .unwrap();
2663
2664    // Add "ref" label in top-left
2665    writeln!(
2666        svg,
2667        r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2668        x = x + notch_size + 4.0,
2669        y = y + state.config.font_size,
2670        fill = theme.ref_text_color,
2671        font = theme.font_family,
2672        size = state.config.font_size - 2.0
2673    )
2674    .unwrap();
2675
2676    // Draw text centered
2677    let text_x = x + box_width / 2.0;
2678    for (i, line) in lines.iter().enumerate() {
2679        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2680        writeln!(
2681            svg,
2682            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2683            x = text_x,
2684            y = text_y,
2685            fill = theme.ref_text_color,
2686            font = theme.font_family,
2687            size = state.config.font_size,
2688            t = escape_xml(line)
2689        )
2690        .unwrap();
2691    }
2692
2693    // Draw output signal arrow if present
2694    if let Some(to) = output_to {
2695        let from_x = x + box_width; // Right edge of ref box
2696        let to_x = state.get_x(to);
2697        let arrow_y = y + box_height - output_padding;
2698
2699        // Calculate arrowhead
2700        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2701        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2702        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2703
2704        // Draw dashed arrow line (response style)
2705        writeln!(
2706            svg,
2707            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
2708            x1 = from_x,
2709            y = arrow_y,
2710            x2 = line_end_x
2711        )
2712        .unwrap();
2713
2714        // Draw arrowhead
2715        writeln!(
2716            svg,
2717            r#"<polygon points="{points}" class="arrowhead"/>"#,
2718            points = arrow_points
2719        )
2720        .unwrap();
2721
2722        // Draw label if present
2723        if let Some(label) = output_label {
2724            let text_x = (from_x + to_x) / 2.0;
2725            writeln!(
2726                svg,
2727                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2728                x = text_x,
2729                y = arrow_y - 8.0,
2730                t = escape_xml(label)
2731            )
2732            .unwrap();
2733        }
2734    }
2735
2736    state.current_y = y + box_height + state.config.row_height + STATE_EXTRA_GAP;
2737}
2738
2739/// Render a description (extended text explanation)
2740fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
2741    let theme = &state.config.theme;
2742    let lines: Vec<&str> = text.split("\\n").collect();
2743    let line_height = state.config.font_size + 4.0;
2744
2745    // Draw text on the left side of the diagram
2746    let x = state.config.padding + 10.0;
2747    let y = state.current_y;
2748
2749    for (i, line) in lines.iter().enumerate() {
2750        let text_y = y + (i as f64 + 0.8) * line_height;
2751        writeln!(
2752            svg,
2753            r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
2754            x = x,
2755            y = text_y,
2756            fill = theme.description_text_color,
2757            font = theme.font_family,
2758            size = state.config.font_size - 1.0,
2759            t = escape_xml(line)
2760        )
2761        .unwrap();
2762    }
2763
2764    state.current_y += lines.len() as f64 * line_height + 10.0;
2765}
2766
2767fn render_block(
2768    svg: &mut String,
2769    state: &mut RenderState,
2770    kind: &BlockKind,
2771    _label: &str,
2772    items: &[Item],
2773    else_items: Option<&[Item]>,
2774    depth: usize,
2775) {
2776    if block_is_parallel(kind) {
2777        state.push_parallel();
2778        let start_y = state.current_y;
2779        let mut max_end_y = start_y;
2780        for item in items {
2781            state.current_y = start_y;
2782            render_items(svg, state, std::slice::from_ref(item), depth);
2783            if state.current_y > max_end_y {
2784                max_end_y = state.current_y;
2785            }
2786        }
2787        let gap = if parallel_needs_gap(items) {
2788            PARALLEL_BLOCK_GAP
2789        } else {
2790            0.0
2791        };
2792        state.current_y = max_end_y + gap;
2793        state.pop_parallel();
2794        return;
2795    }
2796
2797    if matches!(kind, BlockKind::Serial) {
2798        state.push_serial_first_row_pending();
2799        render_items(svg, state, items, depth);
2800        if let Some(else_items) = else_items {
2801            render_items(svg, state, else_items, depth);
2802        }
2803        state.pop_serial_first_row_pending();
2804        return;
2805    }
2806
2807    if !block_has_frame(kind) {
2808        render_items(svg, state, items, depth);
2809        if let Some(else_items) = else_items {
2810            render_items(svg, state, else_items, depth);
2811        }
2812        return;
2813    }
2814
2815    // Note: Block frame, labels, and else separators are rendered by render_block_labels()
2816    // This function only handles Y position tracking and rendering of inner items
2817    // svg is still used for rendering inner items via render_items()
2818
2819    state.current_y += block_header_space(&state.config, depth);
2820
2821    // Render items
2822    render_items(svg, state, items, depth + 1);
2823
2824    // Render else items if present
2825    if let Some(else_items) = else_items {
2826        state.push_else_return_pending();
2827        state.current_y += block_else_spacing(&state.config, depth);
2828        render_items(svg, state, else_items, depth + 1);
2829        state.pop_else_return_pending();
2830    }
2831
2832    let end_y =
2833        state.current_y - state.config.row_height + block_footer_padding(&state.config, depth);
2834
2835    // Set current_y to end of block + margin
2836    state.current_y = end_y + state.config.row_height * 1.0;
2837
2838    // Block frame, labels, and else separators are rendered later by render_block_labels()
2839    // which is called after activations are drawn, so labels appear on top
2840}
2841
2842fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
2843    for (participant, activations) in &state.activations {
2844        let x = state.get_x(participant);
2845        let box_x = x - state.config.activation_width / 2.0;
2846
2847        for (start_y, end_y) in activations {
2848            // If no end_y, extend to footer
2849            let end = end_y.unwrap_or(footer_y);
2850            let height = end - start_y;
2851
2852            if height > 0.0 {
2853                writeln!(
2854                    svg,
2855                    r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
2856                    x = box_x,
2857                    y = start_y,
2858                    w = state.config.activation_width,
2859                    h = height
2860                )
2861                .unwrap();
2862            }
2863        }
2864    }
2865}
2866
2867fn escape_xml(s: &str) -> String {
2868    s.replace('&', "&amp;")
2869        .replace('<', "&lt;")
2870        .replace('>', "&gt;")
2871        .replace('"', "&quot;")
2872        .replace('\'', "&apos;")
2873}
2874
2875#[cfg(test)]
2876mod tests {
2877    use super::*;
2878    use crate::parser::parse;
2879
2880    #[test]
2881    fn test_render_simple() {
2882        let diagram = parse("Alice->Bob: Hello").unwrap();
2883        let svg = render(&diagram);
2884        assert!(svg.contains("<svg"));
2885        assert!(svg.contains("Alice"));
2886        assert!(svg.contains("Bob"));
2887        assert!(svg.contains("Hello"));
2888    }
2889
2890    #[test]
2891    fn test_render_with_note() {
2892        let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
2893        let svg = render(&diagram);
2894        assert!(svg.contains("Thinking"));
2895    }
2896}