osd_core/
renderer.rs

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