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