osd_core/
renderer.rs

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