osd_core/
renderer.rs

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