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