1use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8#[derive(Debug, Clone)]
10pub struct Config {
11 pub padding: f64,
13 pub left_margin: f64,
15 pub right_margin: f64,
17 pub participant_gap: f64,
19 pub header_height: f64,
21 pub row_height: f64,
23 pub participant_width: f64,
25 pub font_size: f64,
27 pub activation_width: f64,
29 pub note_padding: f64,
31 pub block_margin: f64,
33 pub title_height: f64,
35 pub theme: Theme,
37}
38
39impl Default for Config {
40 fn default() -> Self {
41 Self {
47 padding: 10.5, left_margin: 66.0, right_margin: 10.0, participant_gap: 85.0, header_height: 46.0, row_height: 32.0, participant_width: 92.0, font_size: 14.0, activation_width: 8.0, note_padding: 6.0,
57 block_margin: 5.0,
58 title_height: 100.0, theme: Theme::default(),
60 }
61 }
62}
63
64impl Config {
65 pub fn with_theme(mut self, theme: Theme) -> Self {
67 self.theme = theme;
68 self
69 }
70}
71
72#[derive(Debug, Clone)]
74struct BlockBackground {
75 x: f64,
76 y: f64,
77 width: f64,
78 height: f64,
79}
80
81#[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_sections: Vec<(f64, Option<String>)>,
92}
93
94#[derive(Debug, Clone)]
95struct LabelBox {
96 x_min: f64,
97 x_max: f64,
98 y_min: f64,
99 y_max: f64,
100}
101
102struct RenderState {
104 config: Config,
105 participants: Vec<Participant>,
106 participant_x: HashMap<String, f64>,
107 participant_widths: HashMap<String, f64>,
108 current_y: f64,
109 activations: HashMap<String, Vec<(f64, Option<f64>)>>,
110 autonumber: Option<u32>,
111 destroyed: HashMap<String, f64>,
112 has_title: bool,
113 total_width: f64,
114 block_backgrounds: Vec<BlockBackground>,
116 block_labels: Vec<BlockLabel>,
118 footer_style: FooterStyle,
120 else_return_pending: Vec<bool>,
122 serial_first_row_pending: Vec<bool>,
124 parallel_depth: usize,
126 message_label_boxes: Vec<LabelBox>,
128 destroy_marks: Vec<(f64, f64)>,
130}
131
132const TEXT_WIDTH_PADDING: f64 = 41.0;
136const TEXT_WIDTH_SCALE: f64 = 1.3;
137const MESSAGE_WIDTH_PADDING: f64 = 4.0;
138const MESSAGE_WIDTH_SCALE: f64 = 0.82;
139
140fn group_spacing(config: &Config) -> f64 {
147 config.row_height }
149
150const MESSAGE_TEXT_ABOVE_ARROW: f64 = 6.0; const DELAY_UNIT: f64 = 18.0; const BLOCK_LABEL_HEIGHT: f64 = 22.0; const ELEMENT_PADDING: f64 = 8.0; const NOTE_MARGIN: f64 = 10.0; const NOTE_FOLD_SIZE: f64 = 8.0; const NOTE_LINE_HEIGHT: f64 = 17.0; const NOTE_MIN_WIDTH: f64 = 50.0; const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0; const STATE_PADDING: f64 = 12.0; const REF_LINE_HEIGHT_EXTRA: f64 = 16.0; const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
185const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
186const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
187const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
188
189fn block_header_space(config: &Config, _depth: usize) -> f64 {
190 BLOCK_LABEL_HEIGHT + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
192}
193
194fn block_frame_shift(_depth: usize) -> f64 {
195 0.0
196}
197
198fn block_footer_padding(_config: &Config, _depth: usize) -> f64 {
199 ELEMENT_PADDING
200}
201
202fn block_else_before(_config: &Config, _depth: usize) -> f64 {
203 ELEMENT_PADDING
204}
205
206fn block_else_after(config: &Config, _depth: usize) -> f64 {
207 16.0 + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
211}
212
213fn self_message_spacing(config: &Config, lines: usize) -> f64 {
214 let line_height = config.font_size + 4.0;
215 let text_block_height = lines as f64 * line_height;
216 let loop_height = text_block_height.max(25.0);
217 loop_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
221}
222
223fn note_line_height(_config: &Config) -> f64 {
224 NOTE_LINE_HEIGHT
226}
227
228fn note_padding(_config: &Config) -> f64 {
229 ELEMENT_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 0.0
251}
252
253fn serial_first_row_gap(_parallel_depth: usize) -> f64 {
254 0.0
256}
257
258fn state_line_height(config: &Config) -> f64 {
259 config.font_size + STATE_LINE_HEIGHT_EXTRA
260}
261
262fn ref_line_height(config: &Config) -> f64 {
263 config.font_size + REF_LINE_HEIGHT_EXTRA
264}
265
266fn regular_message_y_advance(config: &Config, line_count: usize, delay_offset: f64) -> f64 {
274 let line_height = config.font_size + 4.0;
276 let extra_height = if line_count > 1 {
277 (line_count - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
279 } else {
280 0.0
281 };
282 group_spacing(config) + extra_height + delay_offset
285}
286
287fn self_message_y_advance(config: &Config, line_count: usize) -> f64 {
289 self_message_spacing(config, line_count)
290}
291
292fn note_y_advance(config: &Config, line_count: usize) -> f64 {
294 let note_height = note_padding(config) * 2.0 + line_count as f64 * note_line_height(config);
295 note_height.max(group_spacing(config)) + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
297}
298
299fn state_y_advance(config: &Config, line_count: usize) -> f64 {
301 let box_height = STATE_PADDING * 2.0 + line_count as f64 * state_line_height(config);
302 box_height + group_spacing(config)
306}
307
308fn ref_y_advance(config: &Config, line_count: usize) -> f64 {
310 let box_height = BLOCK_LABEL_HEIGHT + ELEMENT_PADDING * 2.0 + line_count as f64 * ref_line_height(config);
312 box_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
314}
315
316fn description_y_advance(config: &Config, line_count: usize) -> f64 {
318 let line_height = config.font_size + 4.0;
319 line_count as f64 * line_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
321}
322
323fn block_end_y_advance(config: &Config, depth: usize) -> f64 {
325 block_footer_padding(config, depth) + group_spacing(config)
326}
327
328const ARROWHEAD_SIZE: f64 = 10.0;
330
331fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
333 let size = ARROWHEAD_SIZE;
334 let half_width = size * 0.35;
335
336 let tip_x = x;
338 let tip_y = y;
339
340 let back_x = x - size * direction.cos();
342 let back_y = y - size * direction.sin();
343
344 let perp_x = -direction.sin() * half_width;
346 let perp_y = direction.cos() * half_width;
347
348 format!(
349 "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
350 back_x + perp_x,
351 back_y + perp_y,
352 tip_x,
353 tip_y,
354 back_x - perp_x,
355 back_y - perp_y
356 )
357}
358
359fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
361 (y2 - y1).atan2(x2 - x1)
362}
363
364fn block_has_frame(kind: &BlockKind) -> bool {
365 !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
366}
367
368fn block_is_parallel(kind: &BlockKind) -> bool {
369 matches!(kind, BlockKind::Parallel)
370}
371
372fn parallel_needs_gap(items: &[Item]) -> bool {
373 items.iter().any(|item| matches!(item, Item::Block { .. }))
374}
375
376fn text_char_weight(c: char) -> f64 {
377 if c.is_ascii() {
378 if c.is_uppercase() {
379 0.7
380 } else {
381 0.5
382 }
383 } else {
384 1.0 }
386}
387
388fn participant_char_width(c: char) -> f64 {
391 match c {
392 'W' | 'w' => 14.0,
394 'M' | 'm' => 12.5,
395 '@' | '%' => 14.0,
396 'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'H' | 'K' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'X' | 'Y' | 'Z' => 12.0,
398 'F' | 'I' | 'J' | 'L' => 7.0,
400 'o' | 'e' | 'a' | 'n' | 'u' | 'v' | 'x' | 'z' | 'b' | 'd' | 'g' | 'h' | 'k' | 'p' | 'q' | 's' | 'c' | 'y' => 8.5,
402 'i' | 'j' | 'l' => 4.0,
404 't' | 'f' | 'r' => 6.0,
405 ':' => 6.5,
407 '-' | '_' => 7.0,
408 '[' | ']' | '(' | ')' | '{' | '}' => 7.0,
409 '.' | ',' | '\'' | '`' | ';' => 4.0,
410 ' ' => 5.0,
411 '0'..='9' => 9.0,
413 _ if c.is_ascii() => 8.5,
415 _ => 14.0,
417 }
418}
419
420fn calculate_participant_width(name: &str, min_width: f64) -> f64 {
422 let lines: Vec<&str> = name.split("\\n").collect();
423 let max_line_width = lines
424 .iter()
425 .map(|line| line.chars().map(participant_char_width).sum::<f64>())
426 .fold(0.0_f64, |a, b| a.max(b));
427
428 let padding = 50.0;
430
431 (max_line_width + padding).max(min_width)
432}
433
434fn max_weighted_line(text: &str) -> f64 {
435 text.split("\\n")
436 .map(|line| line.chars().map(text_char_weight).sum::<f64>())
437 .fold(0.0_f64, |a, b| a.max(b))
438}
439
440fn estimate_text_width(text: &str, font_size: f64) -> f64 {
442 let weighted = max_weighted_line(text);
443 weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
444}
445
446fn estimate_message_width(text: &str, font_size: f64) -> f64 {
447 let weighted = max_weighted_line(text);
448 weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
449}
450
451fn block_tab_width(kind: &str) -> f64 {
452 (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
453}
454
455fn calculate_note_width(text: &str, _config: &Config) -> f64 {
457 let note_font_size = NOTE_LINE_HEIGHT - 4.0;
459 let text_width = estimate_text_width(text, note_font_size);
460 (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH)
461}
462
463fn calculate_right_margin(
465 participants: &[Participant],
466 items: &[Item],
467 config: &Config,
468) -> f64 {
469 let rightmost_id = match participants.last() {
470 Some(p) => p.id().to_string(),
471 None => return config.right_margin,
472 };
473 let mut max_right_note_width: f64 = 0.0;
474
475 fn process_items_for_right_notes(
476 items: &[Item],
477 rightmost_id: &str,
478 max_width: &mut f64,
479 config: &Config,
480 ) {
481 for item in items {
482 match item {
483 Item::Note {
484 position: NotePosition::Right,
485 participants,
486 text,
487 } => {
488 if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
490 let note_width = calculate_note_width(text, config);
491 if note_width > *max_width {
492 *max_width = note_width;
493 }
494 }
495 }
496 Item::Block {
497 items, else_sections, ..
498 } => {
499 process_items_for_right_notes(items, rightmost_id, max_width, config);
500 for section in else_sections {
501 process_items_for_right_notes(§ion.items, rightmost_id, max_width, config);
502 }
503 }
504 _ => {}
505 }
506 }
507 }
508
509 process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
510
511 if max_right_note_width > 0.0 {
513 (max_right_note_width + NOTE_MARGIN).max(config.right_margin)
514 } else {
515 config.right_margin
516 }
517}
518
519fn calculate_left_margin(
521 participants: &[Participant],
522 items: &[Item],
523 config: &Config,
524) -> f64 {
525 let leftmost_id = match participants.first() {
526 Some(p) => p.id().to_string(),
527 None => return config.padding,
528 };
529 let mut max_left_note_width: f64 = 0.0;
530 let mut max_self_msg_text_width: f64 = 0.0;
531
532 fn process_items_for_left_margin(
533 items: &[Item],
534 leftmost_id: &str,
535 max_note_width: &mut f64,
536 max_self_msg_width: &mut f64,
537 config: &Config,
538 ) {
539 for item in items {
540 match item {
541 Item::Note {
542 position: NotePosition::Left,
543 participants,
544 text,
545 } => {
546 if participants.first().map(|s| s.as_str()) == Some(leftmost_id) {
548 let note_width = calculate_note_width(text, config);
549 if note_width > *max_note_width {
550 *max_note_width = note_width;
551 }
552 }
553 }
554 Item::Message { from, to, text, .. } => {
555 if from == to && from == leftmost_id {
557 let text_width = estimate_message_width(text, config.font_size);
558 if text_width > *max_self_msg_width {
559 *max_self_msg_width = text_width;
560 }
561 }
562 }
563 Item::Block {
564 items, else_sections, ..
565 } => {
566 process_items_for_left_margin(items, leftmost_id, max_note_width, max_self_msg_width, config);
567 for section in else_sections {
568 process_items_for_left_margin(§ion.items, leftmost_id, max_note_width, max_self_msg_width, config);
569 }
570 }
571 _ => {}
572 }
573 }
574 }
575
576 process_items_for_left_margin(items, &leftmost_id, &mut max_left_note_width, &mut max_self_msg_text_width, config);
577
578 let note_margin = if max_left_note_width > 0.0 {
580 max_left_note_width + NOTE_MARGIN
581 } else {
582 0.0
583 };
584 let self_msg_margin = if max_self_msg_text_width > 0.0 {
589 (max_self_msg_text_width - 41.0 + config.padding).max(0.0)
590 } else {
591 0.0
592 };
593
594 note_margin.max(self_msg_margin).max(config.padding)
595}
596
597fn calculate_participant_gaps(
599 participants: &[Participant],
600 items: &[Item],
601 config: &Config,
602) -> Vec<f64> {
603 if participants.len() <= 1 {
604 return vec![];
605 }
606
607 let mut participant_index: HashMap<String, usize> = HashMap::new();
609 for (i, p) in participants.iter().enumerate() {
610 participant_index.insert(p.id().to_string(), i);
611 }
612
613 let min_gap = config.participant_gap;
616 let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
617
618 fn process_items(
620 items: &[Item],
621 participant_index: &HashMap<String, usize>,
622 gaps: &mut Vec<f64>,
623 config: &Config,
624 ) {
625 for item in items {
626 match item {
627 Item::Message { from, to, text, arrow, .. } => {
628 if let (Some(&from_idx), Some(&to_idx)) =
629 (participant_index.get(from), participant_index.get(to))
630 {
631 if from_idx != to_idx {
632 let (min_idx, max_idx) = if from_idx < to_idx {
633 (from_idx, to_idx)
634 } else {
635 (to_idx, from_idx)
636 };
637
638 let text_width = estimate_message_width(text, config.font_size);
639
640 let delay_extra = arrow.delay.map(|d| d as f64 * 86.4).unwrap_or(0.0);
643
644 let gap_count = (max_idx - min_idx) as f64;
647 let activation_space = 40.0; let text_padding = 40.0; let needed_gap = if gap_count == 1.0 {
650 text_width + text_padding + activation_space + delay_extra
652 } else {
653 (text_width + text_padding + activation_space) / gap_count + delay_extra
655 };
656
657 for gap_idx in min_idx..max_idx {
659 if needed_gap > gaps[gap_idx] {
660 gaps[gap_idx] = needed_gap;
661 }
662 }
663 }
664 }
665 }
666 Item::Note {
667 position,
668 participants: note_participants,
669 text,
670 } => {
671 let note_width = calculate_note_width(text, config);
673
674 if let Some(participant) = note_participants.first() {
675 if let Some(&idx) = participant_index.get(participant) {
676 match position {
677 NotePosition::Left => {
678 if idx > 0 {
680 let needed_gap = note_width + NOTE_MARGIN * 2.0;
682 if needed_gap > gaps[idx - 1] {
683 gaps[idx - 1] = needed_gap;
684 }
685 }
686 }
687 NotePosition::Right => {
688 if idx < gaps.len() {
690 let needed_gap = note_width + NOTE_MARGIN * 2.0;
691 if needed_gap > gaps[idx] {
692 gaps[idx] = needed_gap;
693 }
694 }
695 }
696 NotePosition::Over => {
697 }
700 }
701 }
702 }
703 }
704 Item::Block {
705 items, else_sections, ..
706 } => {
707 process_items(items, participant_index, gaps, config);
708 for section in else_sections {
709 process_items(§ion.items, participant_index, gaps, config);
710 }
711 }
712 _ => {}
713 }
714 }
715 }
716
717 process_items(items, &participant_index, &mut gaps, config);
718
719 let max_gap = 645.0;
725 for gap in &mut gaps {
726 if *gap > max_gap {
727 *gap = max_gap;
728 }
729 }
730
731 gaps
732}
733
734impl RenderState {
735 fn new(
736 config: Config,
737 participants: Vec<Participant>,
738 items: &[Item],
739 has_title: bool,
740 footer_style: FooterStyle,
741 ) -> Self {
742 let mut config = config;
743 let mut required_header_height = config.header_height;
748 for p in &participants {
749 let lines = p.name.split("\\n").count();
750 let needed = match p.kind {
751 ParticipantKind::Participant => {
752 if lines <= 1 {
754 46.0
755 } else {
756 108.0 }
758 }
759 ParticipantKind::Actor => {
760 if lines <= 1 {
763 85.0
764 } else {
765 108.0
766 }
767 }
768 };
769 if needed > required_header_height {
770 required_header_height = needed;
771 }
772 }
773 if required_header_height > config.header_height {
774 config.header_height = required_header_height;
775 }
776 let mut participant_widths: HashMap<String, f64> = HashMap::new();
779 let min_width = config.participant_width;
780
781 for p in &participants {
782 let width = calculate_participant_width(&p.name, min_width);
783 participant_widths.insert(p.id().to_string(), width);
784 }
785
786 let gaps = calculate_participant_gaps(&participants, items, &config);
787
788 let left_margin = calculate_left_margin(&participants, items, &config);
790 let right_margin = calculate_right_margin(&participants, items, &config);
792
793 let mut participant_x = HashMap::new();
794 let first_width = participants
795 .first()
796 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
797 .unwrap_or(min_width);
798 let mut current_x = config.padding + left_margin + first_width / 2.0;
799
800 for (i, p) in participants.iter().enumerate() {
801 participant_x.insert(p.id().to_string(), current_x);
802 if i < gaps.len() {
803 let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
804 let next_p = participants.get(i + 1);
805 let next_width = next_p
806 .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
807 .unwrap_or(min_width);
808
809 let current_is_actor = p.kind == ParticipantKind::Actor;
812 let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
813
814 let actor_gap_reduction = 0.0;
817 let _ = (current_is_actor, next_is_actor); let calculated_gap = gaps[i] - actor_gap_reduction;
822
823 let half_widths = (current_width + next_width) / 2.0;
826 let neither_is_actor = !current_is_actor && !next_is_actor;
827
828 let either_is_actor = current_is_actor || next_is_actor;
829 let edge_padding = if calculated_gap > 500.0 {
830 10.0
832 } else if either_is_actor && calculated_gap > 130.0 {
833 33.0
835 } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
836 90.0
838 } else if calculated_gap > 130.0 {
839 49.0
841 } else if calculated_gap > config.participant_gap {
842 25.0
844 } else {
845 let max_width = current_width.max(next_width);
847 let min_width_val = current_width.min(next_width);
848 let width_diff = max_width - min_width_val;
849
850 if max_width > 160.0 && min_width_val > 160.0 {
851 1.8
854 } else if max_width > 160.0 && min_width_val > 140.0 {
855 -7.0
858 } else if max_width > 160.0 && min_width_val < 110.0 {
859 11.3
862 } else if max_width > 160.0 && width_diff > 45.0 {
863 -6.0
866 } else if min_width_val < 115.0 {
867 10.0
870 } else {
871 11.0
873 }
874 };
875
876 let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
877 let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
878 current_x += actual_gap;
879 }
880 }
881
882 let last_width = participants
883 .last()
884 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
885 .unwrap_or(min_width);
886 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
887
888 Self {
889 config,
890 participants,
891 participant_x,
892 participant_widths,
893 current_y: 0.0,
894 activations: HashMap::new(),
895 autonumber: None,
896 destroyed: HashMap::new(),
897 has_title,
898 total_width,
899 block_backgrounds: Vec::new(),
900 block_labels: Vec::new(),
901 footer_style,
902 else_return_pending: Vec::new(),
903 serial_first_row_pending: Vec::new(),
904 parallel_depth: 0,
905 message_label_boxes: Vec::new(),
906 destroy_marks: Vec::new(),
907 }
908 }
909
910 fn get_participant_width(&self, name: &str) -> f64 {
911 *self
912 .participant_widths
913 .get(name)
914 .unwrap_or(&self.config.participant_width)
915 }
916
917 fn get_x(&self, name: &str) -> f64 {
918 if name == "[" {
920 return self.config.padding;
921 }
922 if name == "]" {
923 return self.total_width - self.config.padding;
924 }
925 *self.participant_x.get(name).unwrap_or(&0.0)
926 }
927
928 fn push_else_return_pending(&mut self) {
929 self.else_return_pending.push(true);
930 }
931
932 fn pop_else_return_pending(&mut self) {
933 self.else_return_pending.pop();
934 }
935
936 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
937 if let Some(pending) = self.else_return_pending.last_mut() {
938 if *pending && matches!(arrow.line, LineStyle::Dashed) {
939 *pending = false;
941 }
942 }
943 }
944
945 fn push_serial_first_row_pending(&mut self) {
946 self.serial_first_row_pending.push(true);
947 }
948
949 fn pop_serial_first_row_pending(&mut self) {
950 self.serial_first_row_pending.pop();
951 }
952
953 fn apply_serial_first_row_gap(&mut self) {
954 if let Some(pending) = self.serial_first_row_pending.last_mut() {
955 if *pending {
956 self.current_y += serial_first_row_gap(self.parallel_depth);
957 *pending = false;
958 }
959 }
960 }
961
962 fn reserve_message_label(
963 &mut self,
964 x_min: f64,
965 x_max: f64,
966 mut y_min: f64,
967 mut y_max: f64,
968 step: f64,
969 ) -> f64 {
970 let relevance_threshold = step * 2.0;
973 let relevant_boxes: Vec<&LabelBox> = self
974 .message_label_boxes
975 .iter()
976 .filter(|b| b.y_max + relevance_threshold >= y_min)
977 .collect();
978
979 let mut offset = 0.0;
980 let mut attempts = 0;
981 while relevant_boxes
982 .iter()
983 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
984 && attempts < 20
985 {
986 y_min += step;
987 y_max += step;
988 offset += step;
989 attempts += 1;
990 }
991 self.message_label_boxes.push(LabelBox {
992 x_min,
993 x_max,
994 y_min,
995 y_max,
996 });
997 offset
998 }
999
1000 fn push_parallel(&mut self) {
1001 self.parallel_depth += 1;
1002 }
1003
1004 fn pop_parallel(&mut self) {
1005 if self.parallel_depth > 0 {
1006 self.parallel_depth -= 1;
1007 }
1008 }
1009
1010 fn is_participant_active_at(&self, participant: &str, y: f64) -> bool {
1012 if let Some(acts) = self.activations.get(participant) {
1013 acts.iter().any(|(start_y, end_y)| {
1014 *start_y <= y && end_y.map_or(true, |end| y <= end)
1015 })
1016 } else {
1017 false
1018 }
1019 }
1020
1021 fn get_arrow_start_x(&self, participant: &str, y: f64, going_right: bool) -> f64 {
1023 let x = self.get_x(participant);
1024 if self.is_participant_active_at(participant, y) {
1025 let half_width = self.config.activation_width / 2.0;
1026 if going_right {
1027 x + half_width } else {
1029 x - half_width }
1031 } else {
1032 x
1033 }
1034 }
1035
1036 fn get_arrow_end_x(&self, participant: &str, y: f64, coming_from_right: bool) -> f64 {
1038 let x = self.get_x(participant);
1039 if self.is_participant_active_at(participant, y) {
1040 let half_width = self.config.activation_width / 2.0;
1041 if coming_from_right {
1042 x + half_width } else {
1044 x - half_width }
1046 } else {
1047 x
1048 }
1049 }
1050
1051 fn diagram_width(&self) -> f64 {
1052 let max_block_x2 = self
1054 .block_labels
1055 .iter()
1056 .map(|bl| bl.x2)
1057 .fold(0.0f64, |a, b| a.max(b));
1058 let block_width = if max_block_x2 > 0.0 {
1060 max_block_x2 + self.config.padding
1061 } else {
1062 0.0
1063 };
1064 self.total_width.max(block_width)
1065 }
1066
1067 fn leftmost_x(&self) -> f64 {
1069 self.participants
1070 .first()
1071 .map(|p| self.get_x(p.id()))
1072 .unwrap_or(self.config.padding)
1073 }
1074
1075 fn rightmost_x(&self) -> f64 {
1077 self.participants
1078 .last()
1079 .map(|p| self.get_x(p.id()))
1080 .unwrap_or(self.total_width - self.config.padding)
1081 }
1082
1083 fn block_left(&self) -> f64 {
1085 let leftmost_width = self
1086 .participants
1087 .first()
1088 .map(|p| self.get_participant_width(p.id()))
1089 .unwrap_or(self.config.participant_width);
1090 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
1091 }
1092
1093 fn block_right(&self) -> f64 {
1095 let rightmost_width = self
1096 .participants
1097 .last()
1098 .map(|p| self.get_participant_width(p.id()))
1099 .unwrap_or(self.config.participant_width);
1100 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
1101 }
1102
1103 fn header_top(&self) -> f64 {
1104 if self.has_title {
1105 self.config.padding + self.config.title_height
1106 } else {
1107 self.config.padding
1108 }
1109 }
1110
1111 fn content_start(&self) -> f64 {
1112 self.header_top() + self.config.header_height + self.config.row_height
1115 }
1116
1117 fn next_number(&mut self) -> Option<u32> {
1118 self.autonumber.map(|n| {
1119 self.autonumber = Some(n + 1);
1120 n
1121 })
1122 }
1123
1124 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1126 self.block_backgrounds.push(BlockBackground {
1127 x,
1128 y,
1129 width,
1130 height,
1131 });
1132 }
1133
1134 fn add_block_label(
1136 &mut self,
1137 x1: f64,
1138 start_y: f64,
1139 end_y: f64,
1140 x2: f64,
1141 kind: &str,
1142 label: &str,
1143 else_sections: Vec<(f64, Option<String>)>,
1144 ) {
1145 self.block_labels.push(BlockLabel {
1146 x1,
1147 start_y,
1148 end_y,
1149 x2,
1150 kind: kind.to_string(),
1151 label: label.to_string(),
1152 else_sections,
1153 });
1154 }
1155}
1156
1157fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1159 let mut min_left: Option<f64> = None;
1160 let mut max_right: Option<f64> = None;
1161 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1162 let mut includes_leftmost = false;
1163
1164 fn update_bounds(
1165 participant: &str,
1166 state: &RenderState,
1167 min_left: &mut Option<f64>,
1168 max_right: &mut Option<f64>,
1169 includes_leftmost: &mut bool,
1170 leftmost_id: &str,
1171 ) {
1172 let x = state.get_x(participant);
1173 if x > 0.0 {
1174 let width = state.get_participant_width(participant);
1175 let left = x - width / 2.0;
1176 let right = x + width / 2.0;
1177 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1178 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1179 if participant == leftmost_id {
1180 *includes_leftmost = true;
1181 }
1182 }
1183 }
1184
1185 fn process_items(
1186 items: &[Item],
1187 state: &RenderState,
1188 min_left: &mut Option<f64>,
1189 max_right: &mut Option<f64>,
1190 includes_leftmost: &mut bool,
1191 leftmost_id: &str,
1192 ) {
1193 for item in items {
1194 match item {
1195 Item::Message { from, to, .. } => {
1196 update_bounds(
1197 from,
1198 state,
1199 min_left,
1200 max_right,
1201 includes_leftmost,
1202 leftmost_id,
1203 );
1204 update_bounds(
1205 to,
1206 state,
1207 min_left,
1208 max_right,
1209 includes_leftmost,
1210 leftmost_id,
1211 );
1212 }
1213 Item::Note { participants, .. } => {
1214 for p in participants {
1215 update_bounds(
1216 p,
1217 state,
1218 min_left,
1219 max_right,
1220 includes_leftmost,
1221 leftmost_id,
1222 );
1223 }
1224 }
1225 Item::Block {
1226 items, else_sections, ..
1227 } => {
1228 process_items(
1229 items,
1230 state,
1231 min_left,
1232 max_right,
1233 includes_leftmost,
1234 leftmost_id,
1235 );
1236 for section in else_sections {
1237 process_items(
1238 §ion.items,
1239 state,
1240 min_left,
1241 max_right,
1242 includes_leftmost,
1243 leftmost_id,
1244 );
1245 }
1246 }
1247 Item::Activate { participant }
1248 | Item::Deactivate { participant }
1249 | Item::Destroy { participant } => {
1250 update_bounds(
1251 participant,
1252 state,
1253 min_left,
1254 max_right,
1255 includes_leftmost,
1256 leftmost_id,
1257 );
1258 }
1259 _ => {}
1260 }
1261 }
1262 }
1263
1264 process_items(
1265 items,
1266 state,
1267 &mut min_left,
1268 &mut max_right,
1269 &mut includes_leftmost,
1270 leftmost_id,
1271 );
1272
1273 match (min_left, max_right) {
1274 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1275 _ => None,
1276 }
1277}
1278
1279const NESTED_BLOCK_INSET: f64 = 5.0;
1281
1282fn calculate_block_bounds_with_label(
1284 items: &[Item],
1285 else_sections: &[crate::ast::ElseSection],
1286 label: &str,
1287 kind: &str,
1288 _depth: usize,
1289 state: &RenderState,
1290 parent_bounds: Option<(f64, f64)>,
1291) -> (f64, f64) {
1292 let mut all_items: Vec<&Item> = items.iter().collect();
1293 for section in else_sections {
1294 all_items.extend(section.items.iter());
1295 }
1296
1297 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1299
1300 let (constraint_x1, constraint_x2) = if let Some((px1, px2)) = parent_bounds {
1302 (px1 + NESTED_BLOCK_INSET, px2 - NESTED_BLOCK_INSET)
1303 } else {
1304 (state.block_left(), state.block_right())
1305 };
1306
1307 let (base_x1, base_x2) =
1308 if let Some((min_left, max_right, _includes_leftmost)) =
1309 find_involved_participants(&items_slice, state)
1310 {
1311 let margin = state.config.block_margin;
1312 let x1 = (min_left - margin).max(constraint_x1);
1314 let x2 = (max_right + margin).min(constraint_x2);
1315 (x1, x2)
1316 } else {
1317 (constraint_x1, constraint_x2)
1319 };
1320
1321 let pentagon_width = block_tab_width(kind);
1324 let label_font_size = state.config.font_size - 1.0;
1325 let label_padding_x = 6.0;
1326 let condition_width = if label.is_empty() {
1327 0.0
1328 } else {
1329 let condition_text = format!("[{}]", label);
1330 let base_width =
1331 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1332 base_width + label_padding_x * 2.0
1333 };
1334
1335 let mut max_else_label_width = 0.0f64;
1337 for section in else_sections {
1338 if let Some(el) = §ion.label {
1339 if !el.is_empty() {
1340 let else_text = format!("[{}]", el);
1341 let base_width =
1342 (estimate_text_width(&else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1343 let width = base_width + label_padding_x * 2.0;
1344 max_else_label_width = max_else_label_width.max(width);
1345 }
1346 }
1347 }
1348
1349 let max_label_content_width = condition_width.max(max_else_label_width);
1351 let min_label_width = pentagon_width + 8.0 + max_label_content_width + 20.0; let available_width = constraint_x2 - constraint_x1;
1355
1356 let current_width = base_x2 - base_x1;
1358 let (x1, x2) = if current_width < min_label_width {
1359 let desired_x2 = base_x1 + min_label_width;
1361 if desired_x2 <= constraint_x2 {
1362 (base_x1, desired_x2)
1363 } else if min_label_width <= available_width {
1364 (constraint_x2 - min_label_width, constraint_x2)
1366 } else {
1367 (constraint_x1, constraint_x2)
1369 }
1370 } else {
1371 (base_x1, base_x2)
1372 };
1373
1374 (x1, x2)
1375}
1376
1377fn collect_block_backgrounds(
1379 state: &mut RenderState,
1380 items: &[Item],
1381 depth: usize,
1382 active_activation_count: &mut usize,
1383 parent_bounds: Option<(f64, f64)>,
1384) {
1385 for item in items {
1386 match item {
1387 Item::Message {
1388 text,
1389 from,
1390 to,
1391 arrow,
1392 activate,
1393 deactivate,
1394 create,
1395 ..
1396 } => {
1397 state.apply_else_return_gap(arrow);
1398 let is_self = from == to;
1399 let line_count = text.split("\\n").count();
1400 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1401
1402 if is_self {
1403 state.current_y += self_message_y_advance(&state.config, line_count);
1404 } else {
1405 state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1406 }
1407
1408 if *create {
1409 state.current_y += state.config.row_height;
1410 }
1411
1412 state.apply_serial_first_row_gap();
1413 if *activate {
1414 *active_activation_count += 1;
1415 }
1416 if *deactivate && *active_activation_count > 0 {
1417 *active_activation_count -= 1;
1418 }
1419 }
1420 Item::Note { text, .. } => {
1421 let line_count = text.split("\\n").count();
1422 state.current_y += note_y_advance(&state.config, line_count);
1423 }
1424 Item::State { text, .. } => {
1425 let line_count = text.split("\\n").count();
1426 state.current_y += state_y_advance(&state.config, line_count);
1427 }
1428 Item::Ref { text, .. } => {
1429 let line_count = text.split("\\n").count();
1430 state.current_y += ref_y_advance(&state.config, line_count);
1431 }
1432 Item::Description { text } => {
1433 let line_count = text.split("\\n").count();
1434 state.current_y += description_y_advance(&state.config, line_count);
1435 }
1436 Item::Destroy { .. } => {
1437 state.current_y += state.config.row_height;
1438 }
1439 Item::Activate { .. } => {
1440 *active_activation_count += 1;
1441 }
1442 Item::Deactivate { .. } => {
1443 if *active_activation_count > 0 {
1444 *active_activation_count -= 1;
1445 }
1446 }
1447 Item::Block {
1448 kind,
1449 label,
1450 items,
1451 else_sections,
1452 } => {
1453 if block_is_parallel(kind) {
1454 state.push_parallel();
1455 let start_y = state.current_y;
1456 let mut max_end_y = start_y;
1457 let start_activation_count = *active_activation_count;
1458 for item in items {
1459 state.current_y = start_y;
1460 *active_activation_count = start_activation_count;
1461 collect_block_backgrounds(
1462 state,
1463 std::slice::from_ref(item),
1464 depth,
1465 active_activation_count,
1466 parent_bounds,
1467 );
1468 if state.current_y > max_end_y {
1469 max_end_y = state.current_y;
1470 }
1471 }
1472 *active_activation_count = start_activation_count;
1473 let gap = if parallel_needs_gap(items) {
1474 state.config.row_height
1475 } else {
1476 0.0
1477 };
1478 state.current_y = max_end_y + gap;
1479 state.pop_parallel();
1480 continue;
1481 }
1482
1483 if matches!(kind, BlockKind::Serial) {
1484 state.push_serial_first_row_pending();
1485 collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1486 for section in else_sections {
1487 collect_block_backgrounds(
1488 state,
1489 §ion.items,
1490 depth,
1491 active_activation_count,
1492 parent_bounds,
1493 );
1494 }
1495 state.pop_serial_first_row_pending();
1496 continue;
1497 }
1498
1499 if !block_has_frame(kind) {
1500 collect_block_backgrounds(state, items, depth, active_activation_count, parent_bounds);
1501 for section in else_sections {
1502 collect_block_backgrounds(
1503 state,
1504 §ion.items,
1505 depth,
1506 active_activation_count,
1507 parent_bounds,
1508 );
1509 }
1510 continue;
1511 }
1512
1513 let start_y = state.current_y;
1514 let frame_shift = block_frame_shift(depth);
1515 let frame_start_y = start_y - frame_shift;
1516
1517 let (x1, x2) = calculate_block_bounds_with_label(
1519 items,
1520 else_sections,
1521 label,
1522 kind.as_str(),
1523 depth,
1524 state,
1525 parent_bounds,
1526 );
1527
1528 state.current_y += block_header_space(&state.config, depth);
1529 collect_block_backgrounds(state, items, depth + 1, active_activation_count, Some((x1, x2)));
1531
1532 let mut else_section_info: Vec<(f64, Option<String>)> = Vec::new();
1534 for section in else_sections {
1535 state.current_y += block_else_before(&state.config, depth);
1537 let else_y = state.current_y;
1538 else_section_info.push((else_y, section.label.clone()));
1539
1540 state.push_else_return_pending();
1541 state.current_y += block_else_after(&state.config, depth);
1543 collect_block_backgrounds(
1545 state,
1546 §ion.items,
1547 depth + 1,
1548 active_activation_count,
1549 Some((x1, x2)),
1550 );
1551 state.pop_else_return_pending();
1552 }
1553
1554 let end_y = state.current_y + block_footer_padding(&state.config, depth);
1557 let frame_end_y = end_y - frame_shift;
1558 state.current_y = end_y + state.config.row_height;
1559
1560 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1562 state.add_block_label(
1564 x1,
1565 frame_start_y,
1566 frame_end_y,
1567 x2,
1568 kind.as_str(),
1569 label,
1570 else_section_info,
1571 );
1572 }
1573 _ => {}
1574 }
1575 }
1576}
1577
1578fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1580 let theme = &state.config.theme;
1581 for bg in &state.block_backgrounds {
1582 writeln!(
1583 svg,
1584 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1585 x = bg.x,
1586 y = bg.y,
1587 w = bg.width,
1588 h = bg.height,
1589 fill = theme.block_fill
1590 )
1591 .unwrap();
1592 }
1593}
1594
1595fn render_block_labels(svg: &mut String, state: &RenderState) {
1598 let theme = &state.config.theme;
1599
1600 for bl in &state.block_labels {
1601 let x1 = bl.x1;
1602 let x2 = bl.x2;
1603 let start_y = bl.start_y;
1604 let end_y = bl.end_y;
1605
1606 writeln!(
1608 svg,
1609 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1610 x = x1,
1611 y = start_y,
1612 w = x2 - x1,
1613 h = end_y - start_y
1614 )
1615 .unwrap();
1616
1617 let label_text = &bl.kind;
1619 let label_width = block_tab_width(label_text);
1620 let label_height = BLOCK_LABEL_HEIGHT;
1621 let label_text_offset = 16.0;
1622 let notch_size = 5.0;
1623
1624 let pentagon_path = format!(
1626 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1627 x1 = x1,
1628 y1 = start_y,
1629 x2 = x1 + label_width,
1630 y2 = start_y + label_height - notch_size,
1631 x3 = x1 + label_width - notch_size,
1632 y3 = start_y + label_height
1633 );
1634
1635 writeln!(
1636 svg,
1637 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1638 path = pentagon_path,
1639 fill = theme.block_label_fill,
1640 stroke = theme.block_stroke
1641 )
1642 .unwrap();
1643
1644 writeln!(
1646 svg,
1647 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1648 x = x1 + 5.0,
1649 y = start_y + label_text_offset,
1650 kind = label_text
1651 )
1652 .unwrap();
1653
1654 if !bl.label.is_empty() {
1656 let condition_text = format!("[{}]", bl.label);
1657 let text_x = x1 + label_width + 8.0;
1658 let text_y = start_y + label_text_offset;
1659
1660 writeln!(
1661 svg,
1662 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1663 x = text_x,
1664 y = text_y,
1665 label = escape_xml(&condition_text)
1666 )
1667 .unwrap();
1668 }
1669
1670 for (else_y, else_label_opt) in &bl.else_sections {
1672 writeln!(
1674 svg,
1675 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1676 x1 = x1,
1677 y = else_y,
1678 x2 = x2,
1679 c = theme.block_stroke
1680 )
1681 .unwrap();
1682
1683 if let Some(else_label_text) = else_label_opt {
1685 let condition_text = format!("[{}]", else_label_text);
1686 let text_x = x1 + label_width + 8.0; let text_y = else_y + label_text_offset; writeln!(
1690 svg,
1691 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1692 x = text_x,
1693 y = text_y,
1694 label = escape_xml(&condition_text)
1695 )
1696 .unwrap();
1697 }
1698 }
1699 }
1700}
1701
1702pub fn render(diagram: &Diagram) -> String {
1704 render_with_config(diagram, Config::default())
1705}
1706
1707pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1709 let participants = diagram.participants();
1710 let has_title = diagram.title.is_some();
1711 let footer_style = diagram.options.footer;
1712 let mut state = RenderState::new(
1713 config,
1714 participants,
1715 &diagram.items,
1716 has_title,
1717 footer_style,
1718 );
1719 let mut svg = String::new();
1720
1721 let content_height = calculate_height(&diagram.items, &state.config, 0);
1723 let title_space = if has_title {
1724 state.config.title_height
1725 } else {
1726 0.0
1727 };
1728 let footer_space = match footer_style {
1729 FooterStyle::Box => state.config.header_height,
1730 FooterStyle::Bar | FooterStyle::None => 0.0,
1731 };
1732 let footer_label_extra = match footer_style {
1733 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1734 FooterStyle::Bar | FooterStyle::None => 0.0,
1735 };
1736 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1738 + title_space
1739 + state.config.header_height
1740 + content_height
1741 + footer_margin
1742 + footer_space;
1743 let total_height = base_total_height + footer_label_extra;
1744
1745 state.current_y = state.content_start();
1747 let mut active_activation_count = 0;
1748 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count, None);
1749
1750 let total_width = state.diagram_width();
1751
1752 writeln!(
1754 &mut svg,
1755 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1756 w = total_width,
1757 h = total_height
1758 )
1759 .unwrap();
1760
1761 let theme = &state.config.theme;
1763 let lifeline_dash = match theme.lifeline_style {
1764 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1765 LifelineStyle::Solid => "",
1766 };
1767
1768 svg.push_str("<defs>\n");
1769 svg.push_str("<style>\n");
1770 writeln!(
1771 &mut svg,
1772 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1773 fill = theme.participant_fill,
1774 stroke = theme.participant_stroke
1775 )
1776 .unwrap();
1777 writeln!(
1778 &mut svg,
1779 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1780 f = theme.font_family,
1781 s = state.config.font_size,
1782 c = theme.participant_text
1783 )
1784 .unwrap();
1785 writeln!(
1786 &mut svg,
1787 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1788 c = theme.lifeline_color,
1789 dash = lifeline_dash
1790 )
1791 .unwrap();
1792 writeln!(
1793 &mut svg,
1794 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1795 c = theme.message_color
1796 )
1797 .unwrap();
1798 writeln!(
1799 &mut svg,
1800 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1801 c = theme.message_color
1802 )
1803 .unwrap();
1804 writeln!(
1805 &mut svg,
1806 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1807 f = theme.font_family,
1808 s = state.config.font_size,
1809 c = theme.message_text_color
1810 )
1811 .unwrap();
1812 writeln!(
1813 &mut svg,
1814 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1815 fill = theme.note_fill,
1816 stroke = theme.note_stroke
1817 )
1818 .unwrap();
1819 writeln!(
1820 &mut svg,
1821 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1822 f = theme.font_family,
1823 s = state.config.font_size - 1.0,
1824 c = theme.note_text_color
1825 )
1826 .unwrap();
1827 writeln!(
1828 &mut svg,
1829 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1830 c = theme.block_stroke
1831 )
1832 .unwrap();
1833 writeln!(
1834 &mut svg,
1835 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1836 f = theme.font_family,
1837 s = state.config.font_size - 1.0,
1838 c = theme.message_text_color
1839 )
1840 .unwrap();
1841 writeln!(
1842 &mut svg,
1843 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1844 fill = theme.activation_fill,
1845 stroke = theme.activation_stroke
1846 )
1847 .unwrap();
1848 writeln!(
1849 &mut svg,
1850 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1851 fill = theme.actor_fill,
1852 stroke = theme.actor_stroke
1853 )
1854 .unwrap();
1855 writeln!(
1856 &mut svg,
1857 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1858 c = theme.actor_stroke
1859 )
1860 .unwrap();
1861 writeln!(
1862 &mut svg,
1863 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1864 f = theme.font_family,
1865 s = state.config.font_size + 4.0,
1866 c = theme.message_text_color
1867 )
1868 .unwrap();
1869 writeln!(
1871 &mut svg,
1872 ".arrowhead {{ fill: {c}; stroke: none; }}",
1873 c = theme.message_color
1874 )
1875 .unwrap();
1876 writeln!(
1877 &mut svg,
1878 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1879 c = theme.message_color
1880 )
1881 .unwrap();
1882 svg.push_str("</style>\n");
1883 svg.push_str("</defs>\n");
1884
1885 writeln!(
1887 &mut svg,
1888 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1889 bg = theme.background
1890 )
1891 .unwrap();
1892
1893 if let Some(title) = &diagram.title {
1895 let title_y = state.config.padding + state.config.font_size + 7.36; writeln!(
1897 &mut svg,
1898 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1899 x = total_width / 2.0,
1900 y = title_y,
1901 t = escape_xml(title)
1902 )
1903 .unwrap();
1904 }
1905
1906 let header_y = state.header_top();
1908 let footer_y = match footer_style {
1909 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1910 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1911 };
1912
1913 render_block_backgrounds(&mut svg, &state);
1916
1917 state.current_y = state.content_start();
1919
1920 let destroyed_map = collect_destroyed(&diagram.items, &state.config, state.content_start());
1922
1923 let lifeline_start = header_y + state.config.header_height;
1925 let lifeline_end = footer_y;
1926
1927 for p in &state.participants {
1928 let x = state.get_x(p.id());
1929 let end_y = destroyed_map.get(p.id()).copied().unwrap_or(lifeline_end);
1931 writeln!(
1932 &mut svg,
1933 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1934 x = x,
1935 y1 = lifeline_start,
1936 y2 = end_y
1937 )
1938 .unwrap();
1939 }
1940
1941 render_participant_headers(&mut svg, &state, header_y);
1943
1944 state.current_y = state.content_start();
1946 render_items(&mut svg, &mut state, &diagram.items, 0);
1947
1948 render_activations(&mut svg, &mut state, footer_y);
1950
1951 render_destroy_marks(&mut svg, &state);
1953
1954 render_block_labels(&mut svg, &state);
1956
1957 match state.footer_style {
1960 FooterStyle::Box => {
1961 render_participant_footers(&mut svg, &state, footer_y);
1962 }
1963 FooterStyle::Bar => {
1964 let left = state.leftmost_x()
1966 - state.get_participant_width(
1967 state.participants.first().map(|p| p.id()).unwrap_or(""),
1968 ) / 2.0;
1969 let right = state.rightmost_x()
1970 + state
1971 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1972 / 2.0;
1973 writeln!(
1974 &mut svg,
1975 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1976 x1 = left,
1977 y = footer_y,
1978 x2 = right,
1979 c = state.config.theme.lifeline_color
1980 )
1981 .unwrap();
1982 }
1983 FooterStyle::None => {
1984 }
1986 }
1987
1988 svg.push_str("</svg>\n");
1989 svg
1990}
1991
1992fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1993 fn inner(
1994 items: &[Item],
1995 config: &Config,
1996 depth: usize,
1997 else_pending: &mut Vec<bool>,
1998 serial_pending: &mut Vec<bool>,
1999 active_activation_count: &mut usize,
2000 parallel_depth: &mut usize,
2001 ) -> f64 {
2002 let mut height = 0.0;
2003 for item in items {
2004 match item {
2005 Item::Message {
2006 from,
2007 to,
2008 text,
2009 arrow,
2010 create,
2011 activate,
2012 deactivate,
2013 ..
2014 } => {
2015 if let Some(pending) = else_pending.last_mut() {
2016 if *pending && matches!(arrow.line, LineStyle::Dashed) {
2017 *pending = false;
2018 }
2019 }
2020 let is_self = from == to;
2021 let line_count = text.split("\\n").count();
2022 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2023 if is_self {
2024 height += self_message_y_advance(config, line_count);
2025 } else {
2026 height += regular_message_y_advance(config, line_count, delay_offset);
2027 }
2028 if *create {
2029 height += config.row_height;
2030 }
2031 if let Some(pending) = serial_pending.last_mut() {
2032 if *pending {
2033 height += serial_first_row_gap(*parallel_depth);
2034 *pending = false;
2035 }
2036 }
2037 if *activate {
2038 *active_activation_count += 1;
2039 }
2040 if *deactivate && *active_activation_count > 0 {
2041 *active_activation_count -= 1;
2042 }
2043 }
2044 Item::Note { text, .. } => {
2045 let line_count = text.split("\\n").count();
2046 height += note_y_advance(config, line_count);
2047 }
2048 Item::State { text, .. } => {
2049 let line_count = text.split("\\n").count();
2050 height += state_y_advance(config, line_count);
2051 }
2052 Item::Ref { text, .. } => {
2053 let line_count = text.split("\\n").count();
2054 height += ref_y_advance(config, line_count);
2055 }
2056 Item::Description { text } => {
2057 let line_count = text.split("\\n").count();
2058 height += description_y_advance(config, line_count);
2059 }
2060 Item::Block {
2061 kind,
2062 items,
2063 else_sections,
2064 ..
2065 } => {
2066 if block_is_parallel(kind) {
2067 let mut max_branch_height = 0.0;
2068 let base_activation_count = *active_activation_count;
2069 *parallel_depth += 1;
2070 for item in items {
2071 *active_activation_count = base_activation_count;
2072 let branch_height = inner(
2073 std::slice::from_ref(item),
2074 config,
2075 depth,
2076 else_pending,
2077 serial_pending,
2078 active_activation_count,
2079 parallel_depth,
2080 );
2081 if branch_height > max_branch_height {
2082 max_branch_height = branch_height;
2083 }
2084 }
2085 *active_activation_count = base_activation_count;
2086 if *parallel_depth > 0 {
2087 *parallel_depth -= 1;
2088 }
2089 let gap = if parallel_needs_gap(items) {
2090 config.row_height
2091 } else {
2092 0.0
2093 };
2094 height += max_branch_height + gap;
2095 continue;
2096 }
2097
2098 if matches!(kind, BlockKind::Serial) {
2099 serial_pending.push(true);
2100 height += inner(
2101 items,
2102 config,
2103 depth,
2104 else_pending,
2105 serial_pending,
2106 active_activation_count,
2107 parallel_depth,
2108 );
2109 for else_section in else_sections {
2110 height += inner(
2111 &else_section.items,
2112 config,
2113 depth,
2114 else_pending,
2115 serial_pending,
2116 active_activation_count,
2117 parallel_depth,
2118 );
2119 }
2120 serial_pending.pop();
2121 } else if !block_has_frame(kind) {
2122 height += inner(
2123 items,
2124 config,
2125 depth,
2126 else_pending,
2127 serial_pending,
2128 active_activation_count,
2129 parallel_depth,
2130 );
2131 for else_section in else_sections {
2132 height += inner(
2133 &else_section.items,
2134 config,
2135 depth,
2136 else_pending,
2137 serial_pending,
2138 active_activation_count,
2139 parallel_depth,
2140 );
2141 }
2142 } else {
2143 height += block_header_space(config, depth);
2144 height += inner(
2145 items,
2146 config,
2147 depth + 1,
2148 else_pending,
2149 serial_pending,
2150 active_activation_count,
2151 parallel_depth,
2152 );
2153 for else_section in else_sections {
2154 else_pending.push(true);
2155 height += block_else_before(config, depth) + block_else_after(config, depth);
2157 height += inner(
2158 &else_section.items,
2159 config,
2160 depth + 1,
2161 else_pending,
2162 serial_pending,
2163 active_activation_count,
2164 parallel_depth,
2165 );
2166 else_pending.pop();
2167 }
2168 height += block_end_y_advance(config, depth);
2170 }
2171 }
2172 Item::Activate { .. } => {
2173 *active_activation_count += 1;
2174 }
2175 Item::Deactivate { .. } => {
2176 if *active_activation_count > 0 {
2177 *active_activation_count -= 1;
2178 }
2179 }
2180 Item::Destroy { .. } => {
2181 height += config.row_height;
2182 }
2183 Item::ParticipantDecl { .. } => {}
2184 Item::Autonumber { .. } => {}
2185 Item::DiagramOption { .. } => {} }
2187 }
2188 height
2189 }
2190
2191 let mut else_pending = Vec::new();
2192 let mut serial_pending = Vec::new();
2193 let mut active_activation_count = 0;
2194 let mut parallel_depth = 0;
2195 inner(
2196 items,
2197 config,
2198 depth,
2199 &mut else_pending,
2200 &mut serial_pending,
2201 &mut active_activation_count,
2202 &mut parallel_depth,
2203 )
2204}
2205
2206fn collect_destroyed(items: &[Item], config: &Config, start_y: f64) -> HashMap<String, f64> {
2209 fn inner(
2210 items: &[Item],
2211 config: &Config,
2212 current_y: &mut f64,
2213 destroyed: &mut HashMap<String, f64>,
2214 ) {
2215 for item in items {
2216 match item {
2217 Item::Message { from, to, text, arrow, .. } => {
2218 let is_self = from == to;
2219 let line_count = text.split("\\n").count();
2220 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2221 if is_self {
2222 *current_y += self_message_y_advance(config, line_count) + delay_offset;
2223 } else {
2224 *current_y += regular_message_y_advance(config, line_count, delay_offset);
2225 }
2226 }
2227 Item::Note { text, .. } => {
2228 let line_count = text.split("\\n").count();
2229 *current_y += note_y_advance(config, line_count);
2230 }
2231 Item::State { text, .. } => {
2232 let line_count = text.split("\\n").count();
2233 *current_y += state_y_advance(config, line_count);
2234 }
2235 Item::Ref { .. } => {
2236 *current_y += ref_y_advance(config, 1);
2237 }
2238 Item::Description { text, .. } => {
2239 let line_count = text.split("\\n").count();
2240 *current_y += description_y_advance(config, line_count);
2241 }
2242 Item::Destroy { participant } => {
2243 let destroy_y = *current_y - config.row_height;
2245 destroyed.insert(participant.clone(), destroy_y);
2246 *current_y += config.row_height;
2247 }
2248 Item::Block { items, else_sections, kind, .. } => {
2249 if block_has_frame(kind) {
2250 *current_y += block_header_space(config, 0);
2251 }
2252 inner(items, config, current_y, destroyed);
2253 for else_section in else_sections {
2254 if block_has_frame(kind) {
2255 *current_y += block_else_before(config, 0) + block_else_after(config, 0);
2256 }
2257 inner(&else_section.items, config, current_y, destroyed);
2258 }
2259 if block_has_frame(kind) {
2260 *current_y += block_end_y_advance(config, 0);
2261 }
2262 }
2263 Item::Activate { .. } | Item::Deactivate { .. } | Item::Autonumber { .. } | Item::ParticipantDecl { .. } | Item::DiagramOption { .. } => {}
2264 }
2265 }
2266 }
2267
2268 let mut destroyed = HashMap::new();
2269 let mut current_y = start_y;
2270 inner(items, config, &mut current_y, &mut destroyed);
2271 destroyed
2272}
2273
2274fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2275 let shape = state.config.theme.participant_shape;
2276
2277 for p in &state.participants {
2278 let x = state.get_x(p.id());
2279 let p_width = state.get_participant_width(p.id());
2280 let box_x = x - p_width / 2.0;
2281
2282 match p.kind {
2283 ParticipantKind::Participant => {
2284 match shape {
2286 ParticipantShape::Rectangle => {
2287 writeln!(
2288 svg,
2289 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2290 x = box_x,
2291 y = y,
2292 w = p_width,
2293 h = state.config.header_height
2294 )
2295 .unwrap();
2296 }
2297 ParticipantShape::RoundedRect => {
2298 writeln!(
2299 svg,
2300 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2301 x = box_x,
2302 y = y,
2303 w = p_width,
2304 h = state.config.header_height
2305 )
2306 .unwrap();
2307 }
2308 ParticipantShape::Circle => {
2309 let rx = p_width / 2.0 - 5.0;
2311 let ry = state.config.header_height / 2.0 - 2.0;
2312 writeln!(
2313 svg,
2314 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2315 cx = x,
2316 cy = y + state.config.header_height / 2.0,
2317 rx = rx,
2318 ry = ry
2319 )
2320 .unwrap();
2321 }
2322 }
2323 let lines: Vec<&str> = p.name.split("\\n").collect();
2325 if lines.len() == 1 {
2326 writeln!(
2327 svg,
2328 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2329 x = x,
2330 y = y + state.config.header_height / 2.0,
2331 name = escape_xml(&p.name)
2332 )
2333 .unwrap();
2334 } else {
2335 let line_height = state.config.font_size + 2.0;
2336 let total_height = lines.len() as f64 * line_height;
2337 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2338 + line_height * 0.8;
2339 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2340 for (i, line) in lines.iter().enumerate() {
2341 let dy = if i == 0 { start_y } else { line_height };
2342 if i == 0 {
2343 writeln!(
2344 svg,
2345 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2346 x = x,
2347 y = dy,
2348 text = escape_xml(line)
2349 )
2350 .unwrap();
2351 } else {
2352 writeln!(
2353 svg,
2354 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2355 x = x,
2356 dy = dy,
2357 text = escape_xml(line)
2358 )
2359 .unwrap();
2360 }
2361 }
2362 writeln!(svg, "</text>").unwrap();
2363 }
2364 }
2365 ParticipantKind::Actor => {
2366 let head_r = 8.0;
2368 let body_len = 12.0;
2369 let arm_len = 10.0;
2370 let leg_len = 10.0;
2371 let figure_height = 38.0; let fig_top = y + 8.0;
2375 let fig_center_y = fig_top + head_r + body_len / 2.0;
2376 let arm_y = fig_center_y + 2.0;
2377
2378 writeln!(
2380 svg,
2381 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2382 x = x,
2383 cy = fig_center_y - body_len / 2.0 - head_r,
2384 r = head_r
2385 )
2386 .unwrap();
2387 writeln!(
2389 svg,
2390 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2391 x = x,
2392 y1 = fig_center_y - body_len / 2.0,
2393 y2 = fig_center_y + body_len / 2.0
2394 )
2395 .unwrap();
2396 writeln!(
2398 svg,
2399 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2400 x1 = x - arm_len,
2401 y = arm_y,
2402 x2 = x + arm_len
2403 )
2404 .unwrap();
2405 writeln!(
2407 svg,
2408 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2409 x = x,
2410 y1 = fig_center_y + body_len / 2.0,
2411 x2 = x - leg_len * 0.6,
2412 y2 = fig_center_y + body_len / 2.0 + leg_len
2413 )
2414 .unwrap();
2415 writeln!(
2417 svg,
2418 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2419 x = x,
2420 y1 = fig_center_y + body_len / 2.0,
2421 x2 = x + leg_len * 0.6,
2422 y2 = fig_center_y + body_len / 2.0 + leg_len
2423 )
2424 .unwrap();
2425 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2427 let name_start_y = fig_top + figure_height + 5.0;
2428 if name_lines.len() == 1 {
2429 writeln!(
2430 svg,
2431 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2432 x = x,
2433 y = name_start_y + state.config.font_size,
2434 name = escape_xml(&p.name)
2435 )
2436 .unwrap();
2437 } else {
2438 let line_height = state.config.font_size + 2.0;
2440 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2441 for (i, line) in name_lines.iter().enumerate() {
2442 if i == 0 {
2443 writeln!(
2444 svg,
2445 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2446 x = x,
2447 y = name_start_y + state.config.font_size,
2448 text = escape_xml(line)
2449 )
2450 .unwrap();
2451 } else {
2452 writeln!(
2453 svg,
2454 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2455 x = x,
2456 dy = line_height,
2457 text = escape_xml(line)
2458 )
2459 .unwrap();
2460 }
2461 }
2462 writeln!(svg, "</text>").unwrap();
2463 }
2464 }
2465 }
2466 }
2467}
2468
2469fn render_participant_footers(svg: &mut String, state: &RenderState, y: f64) {
2471 let shape = state.config.theme.participant_shape;
2472
2473 for p in &state.participants {
2474 if state.destroyed.contains_key(p.id()) {
2476 continue;
2477 }
2478
2479 let x = state.get_x(p.id());
2480 let p_width = state.get_participant_width(p.id());
2481 let box_x = x - p_width / 2.0;
2482
2483 match p.kind {
2484 ParticipantKind::Participant => {
2485 match shape {
2487 ParticipantShape::Rectangle => {
2488 writeln!(
2489 svg,
2490 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2491 x = box_x,
2492 y = y,
2493 w = p_width,
2494 h = state.config.header_height
2495 )
2496 .unwrap();
2497 }
2498 ParticipantShape::RoundedRect => {
2499 writeln!(
2500 svg,
2501 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2502 x = box_x,
2503 y = y,
2504 w = p_width,
2505 h = state.config.header_height
2506 )
2507 .unwrap();
2508 }
2509 ParticipantShape::Circle => {
2510 let r = state.config.header_height / 2.0;
2511 writeln!(
2512 svg,
2513 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2514 cx = x,
2515 cy = y + r,
2516 rx = p_width / 2.0,
2517 ry = r
2518 )
2519 .unwrap();
2520 }
2521 }
2522 let lines: Vec<&str> = p.name.split("\\n").collect();
2524 let line_height = state.config.font_size + 2.0;
2525 let total_text_height = lines.len() as f64 * line_height;
2526 let text_start_y = y + (state.config.header_height - total_text_height) / 2.0 + state.config.font_size;
2527
2528 if lines.len() == 1 {
2529 writeln!(
2530 svg,
2531 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2532 x = x,
2533 y = text_start_y,
2534 name = escape_xml(&p.name)
2535 )
2536 .unwrap();
2537 } else {
2538 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2539 for (i, line) in lines.iter().enumerate() {
2540 if i == 0 {
2541 writeln!(
2542 svg,
2543 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2544 x = x,
2545 y = text_start_y,
2546 text = escape_xml(line)
2547 )
2548 .unwrap();
2549 } else {
2550 writeln!(
2551 svg,
2552 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2553 x = x,
2554 dy = line_height,
2555 text = escape_xml(line)
2556 )
2557 .unwrap();
2558 }
2559 }
2560 writeln!(svg, "</text>").unwrap();
2561 }
2562 }
2563 ParticipantKind::Actor => {
2564 let head_r = 8.0;
2566 let body_len = 12.0;
2567 let arm_len = 10.0;
2568 let leg_len = 10.0;
2569 let figure_height = 38.0;
2570 let fig_top = y + 8.0;
2571 let fig_center_y = fig_top + head_r + body_len / 2.0;
2572 let arm_y = fig_center_y + 2.0;
2573
2574 writeln!(
2576 svg,
2577 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2578 x = x,
2579 cy = fig_center_y - body_len / 2.0 - head_r,
2580 r = head_r
2581 )
2582 .unwrap();
2583 writeln!(
2585 svg,
2586 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2587 x = x,
2588 y1 = fig_center_y - body_len / 2.0,
2589 y2 = fig_center_y + body_len / 2.0
2590 )
2591 .unwrap();
2592 writeln!(
2594 svg,
2595 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2596 x1 = x - arm_len,
2597 y = arm_y,
2598 x2 = x + arm_len
2599 )
2600 .unwrap();
2601 writeln!(
2603 svg,
2604 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2605 x = x,
2606 y1 = fig_center_y + body_len / 2.0,
2607 x2 = x - leg_len * 0.6,
2608 y2 = fig_center_y + body_len / 2.0 + leg_len
2609 )
2610 .unwrap();
2611 writeln!(
2613 svg,
2614 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2615 x = x,
2616 y1 = fig_center_y + body_len / 2.0,
2617 x2 = x + leg_len * 0.6,
2618 y2 = fig_center_y + body_len / 2.0 + leg_len
2619 )
2620 .unwrap();
2621 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2623 let name_start_y = fig_top + figure_height + 5.0;
2624 if name_lines.len() == 1 {
2625 writeln!(
2626 svg,
2627 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2628 x = x,
2629 y = name_start_y + state.config.font_size,
2630 name = escape_xml(&p.name)
2631 )
2632 .unwrap();
2633 } else {
2634 let line_height = state.config.font_size + 2.0;
2635 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2636 for (i, line) in name_lines.iter().enumerate() {
2637 if i == 0 {
2638 writeln!(
2639 svg,
2640 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2641 x = x,
2642 y = name_start_y + state.config.font_size,
2643 text = escape_xml(line)
2644 )
2645 .unwrap();
2646 } else {
2647 writeln!(
2648 svg,
2649 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2650 x = x,
2651 dy = line_height,
2652 text = escape_xml(line)
2653 )
2654 .unwrap();
2655 }
2656 }
2657 writeln!(svg, "</text>").unwrap();
2658 }
2659 }
2660 }
2661 }
2662}
2663
2664fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2665 for item in items {
2666 match item {
2667 Item::Message {
2668 from,
2669 to,
2670 text,
2671 arrow,
2672 activate,
2673 deactivate,
2674 create,
2675 ..
2676 } => {
2677 render_message(
2678 svg,
2679 state,
2680 from,
2681 to,
2682 text,
2683 arrow,
2684 *activate,
2685 *deactivate,
2686 *create,
2687 depth,
2688 );
2689 }
2690 Item::Note {
2691 position,
2692 participants,
2693 text,
2694 } => {
2695 render_note(svg, state, position, participants, text);
2696 }
2697 Item::Block {
2698 kind,
2699 label,
2700 items,
2701 else_sections,
2702 } => {
2703 render_block(svg, state, kind, label, items, else_sections, depth);
2704 }
2705 Item::Activate { participant } => {
2706 let y = state.current_y;
2707 state
2708 .activations
2709 .entry(participant.clone())
2710 .or_default()
2711 .push((y, None));
2712 }
2713 Item::Deactivate { participant } => {
2714 if let Some(acts) = state.activations.get_mut(participant) {
2715 if let Some(act) = acts.last_mut() {
2716 if act.1.is_none() {
2717 act.1 = Some(state.current_y);
2718 }
2719 }
2720 }
2721 }
2722 Item::Destroy { participant } => {
2723 let destroy_y = state.current_y - state.config.row_height;
2726 state.destroyed.insert(participant.clone(), destroy_y);
2727
2728 if let Some(acts) = state.activations.get_mut(participant) {
2730 for act in acts.iter_mut() {
2731 if act.1.is_none() {
2732 act.1 = Some(destroy_y);
2733 }
2734 }
2735 }
2736 let x = state.get_x(participant);
2738 state.destroy_marks.push((x, destroy_y));
2739 state.current_y += state.config.row_height;
2740 }
2741 Item::Autonumber { enabled, start } => {
2742 if *enabled {
2743 state.autonumber = Some(start.unwrap_or(1));
2744 } else {
2745 state.autonumber = None;
2746 }
2747 }
2748 Item::ParticipantDecl { .. } => {
2749 }
2751 Item::State { participants, text } => {
2752 render_state(svg, state, participants, text);
2753 }
2754 Item::Ref {
2755 participants,
2756 text,
2757 input_from,
2758 input_label,
2759 output_to,
2760 output_label,
2761 } => {
2762 render_ref(
2763 svg,
2764 state,
2765 participants,
2766 text,
2767 input_from.as_deref(),
2768 input_label.as_deref(),
2769 output_to.as_deref(),
2770 output_label.as_deref(),
2771 );
2772 }
2773 Item::DiagramOption { .. } => {
2774 }
2776 Item::Description { text } => {
2777 render_description(svg, state, text);
2778 }
2779 }
2780 }
2781}
2782
2783fn render_message(
2784 svg: &mut String,
2785 state: &mut RenderState,
2786 from: &str,
2787 to: &str,
2788 text: &str,
2789 arrow: &Arrow,
2790 activate: bool,
2791 deactivate: bool,
2792 create: bool,
2793 _depth: usize,
2794) {
2795 let base_x1 = state.get_x(from);
2797 let base_x2 = state.get_x(to);
2798
2799 state.apply_else_return_gap(arrow);
2800
2801 let is_self = from == to;
2802 let line_class = match arrow.line {
2803 LineStyle::Solid => "message",
2804 LineStyle::Dashed => "message-dashed",
2805 };
2806 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2807
2808 let num_prefix = state
2810 .next_number()
2811 .map(|n| format!("{}. ", n))
2812 .unwrap_or_default();
2813
2814 let display_text = format!("{}{}", num_prefix, text);
2816 let lines: Vec<&str> = display_text.split("\\n").collect();
2817 let line_height = state.config.font_size + 4.0;
2818 let extra_height = if !is_self && lines.len() > 1 {
2819 (lines.len() - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
2822 } else {
2823 0.0
2824 };
2825
2826 if !is_self && lines.len() > 1 {
2828 state.current_y += extra_height;
2829 }
2830
2831 let y = state.current_y;
2832 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2833
2834 let going_right = base_x2 > base_x1;
2836 let x1 = state.get_arrow_start_x(from, y, going_right);
2837 let x2 = state.get_arrow_end_x(to, y, !going_right);
2838
2839 writeln!(svg, r#"<g class="message">"#).unwrap();
2841
2842 if is_self {
2843 let is_active = state.is_participant_active_at(from, y) || activate;
2847 let activation_offset = if is_active {
2848 state.config.activation_width / 2.0
2849 } else {
2850 0.0
2851 };
2852 let loop_anchor_x = base_x1 + activation_offset;
2853
2854 let loop_width = 40.0;
2855 let text_block_height = lines.len() as f64 * line_height;
2856 let loop_height = text_block_height.max(25.0);
2858 let arrow_end_x = loop_anchor_x;
2859 let arrow_end_y = y + loop_height;
2860 let direction = std::f64::consts::PI;
2862 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2863
2864 writeln!(
2865 svg,
2866 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2867 x1 = loop_anchor_x,
2868 y = y,
2869 x2 = loop_anchor_x + loop_width,
2870 y2 = y + loop_height,
2871 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2872 cls = line_class
2873 )
2874 .unwrap();
2875
2876 if is_filled {
2878 writeln!(
2879 svg,
2880 r#" <polygon points="{points}" class="arrowhead"/>"#,
2881 points = arrow_points
2882 )
2883 .unwrap();
2884 } else {
2885 writeln!(
2886 svg,
2887 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2888 points = arrow_points
2889 )
2890 .unwrap();
2891 }
2892
2893 let text_padding = if is_active { 8.0 } else { 5.0 };
2897 let text_x = base_x1 - activation_offset - text_padding;
2898 for (i, line) in lines.iter().enumerate() {
2899 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height;
2900 writeln!(
2901 svg,
2902 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="end">{t}</text>"#,
2903 x = text_x,
2904 y = line_y,
2905 t = escape_xml(line)
2906 )
2907 .unwrap();
2908 }
2909
2910 writeln!(svg, r#"</g>"#).unwrap();
2912
2913 let spacing = self_message_spacing(&state.config, lines.len());
2914 state.current_y += spacing;
2915 } else {
2916 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2918 let y2 = y + delay_offset;
2919
2920 let text_x = (base_x1 + base_x2) / 2.0;
2922 let text_y = (y + y2) / 2.0 - 6.0; let direction = arrow_direction(x1, y, x2, y2);
2926 let arrow_points = arrowhead_points(x2, y2, direction);
2927
2928 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2930 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2931
2932 writeln!(
2934 svg,
2935 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2936 x1 = x1,
2937 y1 = y,
2938 lx2 = line_end_x,
2939 ly2 = line_end_y,
2940 cls = line_class
2941 )
2942 .unwrap();
2943
2944 if is_filled {
2946 writeln!(
2947 svg,
2948 r#" <polygon points="{points}" class="arrowhead"/>"#,
2949 points = arrow_points
2950 )
2951 .unwrap();
2952 } else {
2953 writeln!(
2954 svg,
2955 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2956 points = arrow_points
2957 )
2958 .unwrap();
2959 }
2960
2961 let max_width = lines
2963 .iter()
2964 .map(|line| estimate_message_width(line, state.config.font_size))
2965 .fold(0.0, f64::max);
2966 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2967 let bottom_line_y = text_y;
2968 let label_offset = if has_label_text {
2969 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2970 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2971 let label_x_min = text_x - max_width / 2.0;
2972 let label_x_max = text_x + max_width / 2.0;
2973 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2974 let raw_offset = state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step);
2975 let max_offset = y - MESSAGE_TEXT_ABOVE_ARROW - bottom_line_y;
2977 raw_offset.min(max_offset.max(0.0))
2978 } else {
2979 0.0
2980 };
2981 let rotation = if delay_offset > 0.0 {
2983 let dx = x2 - x1;
2984 let dy = delay_offset;
2985 let angle_rad = dy.atan2(dx.abs());
2986 let angle_deg = angle_rad.to_degrees();
2987 if dx < 0.0 { -angle_deg } else { angle_deg }
2989 } else {
2990 0.0
2991 };
2992
2993 for (i, line) in lines.iter().enumerate() {
2994 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2995 if rotation.abs() > 0.1 {
2996 writeln!(
2998 svg,
2999 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
3000 x = text_x,
3001 y = line_y,
3002 rot = rotation,
3003 cx = text_x,
3004 cy = line_y,
3005 t = escape_xml(line)
3006 )
3007 .unwrap();
3008 } else {
3009 writeln!(
3010 svg,
3011 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
3012 x = text_x,
3013 y = line_y,
3014 t = escape_xml(line)
3015 )
3016 .unwrap();
3017 }
3018 }
3019
3020 writeln!(svg, r#"</g>"#).unwrap();
3022
3023 state.current_y += state.config.row_height + delay_offset;
3025 }
3026
3027 if create {
3028 state.current_y += state.config.row_height;
3029 }
3030
3031 state.apply_serial_first_row_gap();
3032
3033 if activate {
3035 state
3036 .activations
3037 .entry(to.to_string())
3038 .or_default()
3039 .push((y, None));
3040 }
3041 if deactivate {
3042 if let Some(acts) = state.activations.get_mut(from) {
3043 if let Some(act) = acts.last_mut() {
3044 if act.1.is_none() {
3045 act.1 = Some(y);
3046 }
3047 }
3048 }
3049 }
3050}
3051
3052fn render_note(
3053 svg: &mut String,
3054 state: &mut RenderState,
3055 position: &NotePosition,
3056 participants: &[String],
3057 text: &str,
3058) {
3059 let lines: Vec<&str> = text.split("\\n").collect();
3060 let line_height = note_line_height(&state.config);
3061
3062 let note_font_size = NOTE_LINE_HEIGHT - 4.0; let text_width = estimate_text_width(text, note_font_size);
3066 let content_width = (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
3067 let note_height = ELEMENT_PADDING * 2.0 + lines.len() as f64 * line_height;
3068
3069 let (x, note_width, text_anchor) = match position {
3070 NotePosition::Left => {
3071 let px = state.get_x(&participants[0]);
3072 let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
3074 (x, content_width, "start")
3075 }
3076 NotePosition::Right => {
3077 let px = state.get_x(&participants[0]);
3078 (px + NOTE_MARGIN, content_width, "start")
3080 }
3081 NotePosition::Over => {
3082 if participants.len() == 1 {
3083 let px = state.get_x(&participants[0]);
3084 let x = (px - content_width / 2.0).max(state.config.padding);
3086 (x, content_width, "middle")
3087 } else {
3088 let x1 = state.get_x(&participants[0]);
3091 let x2 = state.get_x(participants.last().unwrap());
3092 let left_x = x1.min(x2);
3093 let right_x = x1.max(x2);
3094 let span_width = (right_x - left_x) + NOTE_MARGIN * 2.0;
3095 let w = span_width.max(content_width);
3096 let x = (left_x - NOTE_MARGIN).max(state.config.padding);
3097 (x, w, "middle")
3098 }
3099 }
3100 };
3101
3102 let y = state.current_y;
3103 let fold_size = NOTE_FOLD_SIZE;
3104
3105 let note_path = format!(
3108 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
3109 x = x,
3110 y = y,
3111 x2 = x + note_width - fold_size,
3112 x3 = x + note_width,
3113 y2 = y + fold_size,
3114 y3 = y + note_height
3115 );
3116
3117 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
3118
3119 let theme = &state.config.theme;
3121 let fold_path = format!(
3123 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
3124 x1 = x + note_width - fold_size,
3125 y1 = y,
3126 x2 = x + note_width,
3127 y2 = y + fold_size
3128 );
3129
3130 writeln!(
3131 svg,
3132 r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
3133 path = fold_path,
3134 stroke = theme.note_stroke
3135 )
3136 .unwrap();
3137
3138 let text_x = match text_anchor {
3140 "middle" => x + note_width / 2.0,
3141 _ => x + ELEMENT_PADDING,
3142 };
3143 let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
3144
3145 for (i, line) in lines.iter().enumerate() {
3146 let text_y = y + ELEMENT_PADDING + (i as f64 + 0.8) * line_height;
3147 writeln!(
3148 svg,
3149 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
3150 x = text_x,
3151 y = text_y,
3152 anchor = text_anchor_attr,
3153 t = escape_xml(line)
3154 )
3155 .unwrap();
3156 }
3157
3158 state.current_y += note_y_advance(&state.config, lines.len());
3160}
3161
3162fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
3164 let theme = &state.config.theme;
3165 let lines: Vec<&str> = text.split("\\n").collect();
3166 let line_height = state_line_height(&state.config);
3167 let box_height = STATE_PADDING * 2.0 + lines.len() as f64 * line_height;
3168
3169 let (x, box_width) = if participants.len() == 1 {
3171 let px = state.get_x(&participants[0]);
3172 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
3173 let w = (max_line_len as f64 * 8.0 + STATE_PADDING * 2.0).max(60.0);
3174 (px - w / 2.0, w)
3175 } else {
3176 let x1 = state.get_x(&participants[0]);
3177 let x2 = state.get_x(participants.last().unwrap());
3178 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
3179 let center = (x1 + x2) / 2.0;
3180 (center - span_width / 2.0, span_width)
3181 };
3182
3183 let y = state.current_y.max(state.content_start());
3185
3186 writeln!(
3188 svg,
3189 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
3190 x = x,
3191 y = y,
3192 w = box_width,
3193 h = box_height,
3194 fill = theme.state_fill,
3195 stroke = theme.state_stroke
3196 )
3197 .unwrap();
3198
3199 let text_x = x + box_width / 2.0;
3201 for (i, line) in lines.iter().enumerate() {
3202 let text_y = y + STATE_PADDING + (i as f64 + 0.8) * line_height;
3203 writeln!(
3204 svg,
3205 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3206 x = text_x,
3207 y = text_y,
3208 fill = theme.state_text_color,
3209 font = theme.font_family,
3210 size = state.config.font_size,
3211 t = escape_xml(line)
3212 )
3213 .unwrap();
3214 }
3215
3216 let line_count = lines.len();
3218 state.current_y += state_y_advance(&state.config, line_count);
3219}
3220
3221fn render_ref(
3223 svg: &mut String,
3224 state: &mut RenderState,
3225 participants: &[String],
3226 text: &str,
3227 input_from: Option<&str>,
3228 input_label: Option<&str>,
3229 output_to: Option<&str>,
3230 output_label: Option<&str>,
3231) {
3232 let theme = &state.config.theme;
3233 let lines: Vec<&str> = text.split("\\n").collect();
3234 let line_height = ref_line_height(&state.config);
3235 let label_height = BLOCK_LABEL_HEIGHT;
3237 let box_height = label_height + state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
3238
3239 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
3241 let text_width = max_line_len as f64 * 8.0 + state.config.note_padding * 4.0;
3242
3243 let (x, box_width) = if participants.len() == 1 {
3244 let px = state.get_x(&participants[0]);
3245 let w = (text_width + 40.0).max(100.0);
3246 (px - w / 2.0, w)
3247 } else {
3248 let x1 = state.get_x(&participants[0]);
3249 let x2 = state.get_x(participants.last().unwrap());
3250 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
3251 let final_width = span_width.max(text_width);
3253 let center = (x1 + x2) / 2.0;
3254 (center - final_width / 2.0, final_width)
3255 };
3256
3257 let shift = item_pre_shift(&state.config);
3258 let y = (state.current_y - shift).max(state.content_start());
3259 let input_arrow_y = y + label_height / 2.0 + 4.0;
3260 let output_arrow_y = y + box_height - label_height / 2.0 - 4.0;
3261
3262 if let Some(from) = input_from {
3264 let from_x = state.get_x(from);
3265 let to_x = if from_x < x + box_width / 2.0 {
3267 x } else {
3269 x + box_width };
3271
3272 let direction = arrow_direction(from_x, input_arrow_y, to_x, input_arrow_y);
3274 let arrow_points = arrowhead_points(to_x, input_arrow_y, direction);
3275 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3276
3277 writeln!(
3279 svg,
3280 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
3281 x1 = from_x,
3282 y = input_arrow_y,
3283 x2 = line_end_x
3284 )
3285 .unwrap();
3286
3287 writeln!(
3289 svg,
3290 r#"<polygon points="{points}" class="arrowhead"/>"#,
3291 points = arrow_points
3292 )
3293 .unwrap();
3294
3295 if let Some(label) = input_label {
3297 let text_x = (from_x + to_x) / 2.0;
3298 writeln!(
3299 svg,
3300 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3301 x = text_x,
3302 y = input_arrow_y - 8.0,
3303 t = escape_xml(label)
3304 )
3305 .unwrap();
3306 }
3307 }
3308
3309 writeln!(
3311 svg,
3312 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
3313 x = x,
3314 y = y,
3315 w = box_width,
3316 h = box_height
3317 )
3318 .unwrap();
3319
3320 let label_text = "ref";
3322 let tab_width = block_tab_width(label_text);
3323 let notch_size = 5.0;
3324 let label_text_offset = 16.0;
3325
3326 let pentagon_path = format!(
3327 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
3328 x1 = x,
3329 y1 = y,
3330 x2 = x + tab_width,
3331 y2 = y + label_height - notch_size,
3332 x3 = x + tab_width - notch_size,
3333 y3 = y + label_height
3334 );
3335
3336 writeln!(
3337 svg,
3338 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
3339 path = pentagon_path,
3340 fill = theme.block_label_fill,
3341 stroke = theme.block_stroke
3342 )
3343 .unwrap();
3344
3345 writeln!(
3347 svg,
3348 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
3349 x = x + 5.0,
3350 y = y + label_text_offset,
3351 label = label_text
3352 )
3353 .unwrap();
3354
3355 let text_x = x + box_width / 2.0;
3357 for (i, line) in lines.iter().enumerate() {
3358 let text_y = y + label_height + state.config.note_padding + (i as f64 + 0.5) * line_height;
3359 writeln!(
3360 svg,
3361 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3362 x = text_x,
3363 y = text_y,
3364 fill = theme.ref_text_color,
3365 font = theme.font_family,
3366 size = state.config.font_size,
3367 t = escape_xml(line)
3368 )
3369 .unwrap();
3370 }
3371
3372 if let Some(to) = output_to {
3374 let to_x = state.get_x(to);
3375 let from_x = if to_x < x + box_width / 2.0 {
3377 x } else {
3379 x + box_width };
3381
3382 let direction = arrow_direction(from_x, output_arrow_y, to_x, output_arrow_y);
3384 let arrow_points = arrowhead_points(to_x, output_arrow_y, direction);
3385 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3386
3387 writeln!(
3389 svg,
3390 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
3391 x1 = from_x,
3392 y = output_arrow_y,
3393 x2 = line_end_x
3394 )
3395 .unwrap();
3396
3397 writeln!(
3399 svg,
3400 r#"<polygon points="{points}" class="arrowhead"/>"#,
3401 points = arrow_points
3402 )
3403 .unwrap();
3404
3405 if let Some(label) = output_label {
3407 let text_x = (from_x + to_x) / 2.0;
3408 writeln!(
3409 svg,
3410 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3411 x = text_x,
3412 y = output_arrow_y - 8.0,
3413 t = escape_xml(label)
3414 )
3415 .unwrap();
3416 }
3417 }
3418
3419 let line_count = lines.len();
3421 state.current_y += ref_y_advance(&state.config, line_count);
3422}
3423
3424fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3426 let theme = &state.config.theme;
3427 let lines: Vec<&str> = text.split("\\n").collect();
3428 let line_height = state.config.font_size + 4.0;
3429
3430 let x = state.config.padding + 10.0;
3432 let y = state.current_y;
3433
3434 for (i, line) in lines.iter().enumerate() {
3435 let text_y = y + (i as f64 + 0.8) * line_height;
3436 writeln!(
3437 svg,
3438 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3439 x = x,
3440 y = text_y,
3441 fill = theme.description_text_color,
3442 font = theme.font_family,
3443 size = state.config.font_size - 1.0,
3444 t = escape_xml(line)
3445 )
3446 .unwrap();
3447 }
3448
3449 state.current_y += description_y_advance(&state.config, lines.len());
3450}
3451
3452fn render_block(
3453 svg: &mut String,
3454 state: &mut RenderState,
3455 kind: &BlockKind,
3456 _label: &str,
3457 items: &[Item],
3458 else_sections: &[crate::ast::ElseSection],
3459 depth: usize,
3460) {
3461 if block_is_parallel(kind) {
3462 state.push_parallel();
3463 let start_y = state.current_y;
3464 let mut max_end_y = start_y;
3465 for item in items {
3466 state.current_y = start_y;
3467 render_items(svg, state, std::slice::from_ref(item), depth);
3468 if state.current_y > max_end_y {
3469 max_end_y = state.current_y;
3470 }
3471 }
3472 let gap = if parallel_needs_gap(items) {
3473 state.config.row_height
3474 } else {
3475 0.0
3476 };
3477 state.current_y = max_end_y + gap;
3478 state.pop_parallel();
3479 return;
3480 }
3481
3482 if matches!(kind, BlockKind::Serial) {
3483 state.push_serial_first_row_pending();
3484 render_items(svg, state, items, depth);
3485 for else_section in else_sections {
3486 render_items(svg, state, &else_section.items, depth);
3487 }
3488 state.pop_serial_first_row_pending();
3489 return;
3490 }
3491
3492 if !block_has_frame(kind) {
3493 render_items(svg, state, items, depth);
3494 for else_section in else_sections {
3495 render_items(svg, state, &else_section.items, depth);
3496 }
3497 return;
3498 }
3499
3500 state.current_y += block_header_space(&state.config, depth);
3505
3506 render_items(svg, state, items, depth + 1);
3508
3509 for else_section in else_sections {
3511 state.push_else_return_pending();
3512 state.current_y += block_else_before(&state.config, depth);
3514 state.current_y += block_else_after(&state.config, depth);
3516 render_items(svg, state, &else_section.items, depth + 1);
3517 state.pop_else_return_pending();
3518 }
3519
3520 let end_y = state.current_y + block_footer_padding(&state.config, depth);
3523
3524 state.current_y = end_y + state.config.row_height;
3526
3527 }
3530
3531fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3532 for (participant, activations) in &state.activations {
3533 let x = state.get_x(participant);
3534 let box_x = x - state.config.activation_width / 2.0;
3535
3536 for (start_y, end_y) in activations {
3537 let end = end_y.unwrap_or(footer_y);
3539 let height = end - start_y;
3540
3541 if height > 0.0 {
3542 writeln!(
3543 svg,
3544 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3545 x = box_x,
3546 y = start_y,
3547 w = state.config.activation_width,
3548 h = height
3549 )
3550 .unwrap();
3551 }
3552 }
3553 }
3554}
3555
3556fn render_destroy_marks(svg: &mut String, state: &RenderState) {
3558 let size = 15.0; for (x, y) in &state.destroy_marks {
3560 writeln!(
3562 svg,
3563 r##"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#cc0000" stroke-width="2"/>"##,
3564 x1 = x - size,
3565 y1 = y - size,
3566 x2 = x + size,
3567 y2 = y + size
3568 )
3569 .unwrap();
3570 writeln!(
3571 svg,
3572 r##"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#cc0000" stroke-width="2"/>"##,
3573 x1 = x + size,
3574 y1 = y - size,
3575 x2 = x - size,
3576 y2 = y + size
3577 )
3578 .unwrap();
3579 }
3580}
3581
3582fn escape_xml(s: &str) -> String {
3583 s.replace('&', "&")
3584 .replace('<', "<")
3585 .replace('>', ">")
3586 .replace('"', """)
3587 .replace('\'', "'")
3588}
3589
3590#[cfg(test)]
3591mod tests {
3592 use super::*;
3593 use crate::parser::parse;
3594
3595 #[test]
3596 fn test_render_simple() {
3597 let diagram = parse("Alice->Bob: Hello").unwrap();
3598 let svg = render(&diagram);
3599 assert!(svg.contains("<svg"));
3600 assert!(svg.contains("Alice"));
3601 assert!(svg.contains("Bob"));
3602 assert!(svg.contains("Hello"));
3603 }
3604
3605 #[test]
3606 fn test_render_with_note() {
3607 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3608 let svg = render(&diagram);
3609 assert!(svg.contains("Thinking"));
3610 }
3611}