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