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