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_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
101struct 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 block_backgrounds: Vec<BlockBackground>,
115 block_labels: Vec<BlockLabel>,
117 footer_style: FooterStyle,
119 else_return_pending: Vec<bool>,
121 serial_first_row_pending: Vec<bool>,
123 parallel_depth: usize,
125 message_label_boxes: Vec<LabelBox>,
127}
128
129const TEXT_WIDTH_PADDING: f64 = 41.0;
133const TEXT_WIDTH_SCALE: f64 = 1.3;
134const MESSAGE_WIDTH_PADDING: f64 = 4.0;
135const MESSAGE_WIDTH_SCALE: f64 = 0.82;
136
137const MESSAGE_TEXT_ABOVE_ARROW: f64 = 6.0; const SELF_MESSAGE_MIN_SPACING: f64 = 54.0; const SELF_MESSAGE_GAP: f64 = 14.0; const SELF_MESSAGE_PRE_GAP_REDUCTION: f64 = 9.0; const CREATE_MESSAGE_SPACING: f64 = 28.0; const DESTROY_SPACING: f64 = 11.0; const DELAY_UNIT: f64 = 18.0; const MESSAGE_MULTILINE_HEIGHT_MULT: f64 = 0.375; const BLOCK_LABEL_HEIGHT: f64 = 22.0; const BLOCK_TITLE_PADDING: f64 = 12.0; const BLOCK_FOOTER_PADDING: f64 = 8.0; const BLOCK_ELSE_BEFORE: f64 = 8.0; const BLOCK_ELSE_AFTER: f64 = 32.0; const BLOCK_GAP: f64 = 14.0; const NOTE_PADDING: f64 = 8.0; const NOTE_MARGIN: f64 = 10.0; const NOTE_FOLD_SIZE: f64 = 8.0; const NOTE_CHAR_WIDTH: f64 = 7.0; const NOTE_LINE_HEIGHT: f64 = 17.0; const NOTE_MIN_WIDTH: f64 = 50.0; const ROW_SPACING: f64 = 20.0; const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0; const REF_LINE_HEIGHT_EXTRA: f64 = 16.0; const REF_EXTRA_GAP: f64 = 3.0; const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
181const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
182const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
183const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
184
185fn block_header_space(_config: &Config, _depth: usize) -> f64 {
186 BLOCK_LABEL_HEIGHT + BLOCK_TITLE_PADDING + MESSAGE_TEXT_ABOVE_ARROW
189}
190
191fn block_frame_shift(_depth: usize) -> f64 {
192 0.0
194}
195
196fn block_footer_padding(_config: &Config, _depth: usize) -> f64 {
197 BLOCK_FOOTER_PADDING
199}
200
201fn block_else_before(_config: &Config, _depth: usize) -> f64 {
202 BLOCK_ELSE_BEFORE
203}
204
205fn block_else_after(_config: &Config, _depth: usize) -> f64 {
206 BLOCK_ELSE_AFTER
207}
208
209fn message_spacing_line_height(config: &Config) -> f64 {
210 config.row_height * MESSAGE_MULTILINE_HEIGHT_MULT
211}
212
213fn self_message_spacing(config: &Config, lines: usize) -> f64 {
214 let line_height = config.font_size + 4.0;
215 let text_block_height = lines as f64 * line_height;
216 let loop_height = text_block_height.max(25.0);
218 let base = loop_height + SELF_MESSAGE_GAP;
220 if lines >= 3 {
221 base.max(SELF_MESSAGE_MIN_SPACING)
222 } else {
223 base
224 }
225}
226
227fn note_line_height(_config: &Config) -> f64 {
228 NOTE_LINE_HEIGHT
230}
231
232fn note_padding(_config: &Config) -> f64 {
233 NOTE_PADDING
234}
235
236fn item_pre_gap(config: &Config) -> f64 {
237 config.font_size + 1.0
238}
239
240fn item_pre_shift(config: &Config) -> f64 {
241 (config.row_height - item_pre_gap(config)).max(0.0)
242}
243
244fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
245 let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
246 && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
247 let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
248 && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
249 x_overlap && y_overlap
250}
251
252fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
253 0.0
255}
256
257fn serial_first_row_gap(_parallel_depth: usize) -> f64 {
258 0.0
260}
261
262fn state_line_height(config: &Config) -> f64 {
263 config.font_size + STATE_LINE_HEIGHT_EXTRA
264}
265
266fn ref_line_height(config: &Config) -> f64 {
267 config.font_size + REF_LINE_HEIGHT_EXTRA
268}
269
270const ARROWHEAD_SIZE: f64 = 10.0;
272
273fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
275 let size = ARROWHEAD_SIZE;
276 let half_width = size * 0.35;
277
278 let tip_x = x;
280 let tip_y = y;
281
282 let back_x = x - size * direction.cos();
284 let back_y = y - size * direction.sin();
285
286 let perp_x = -direction.sin() * half_width;
288 let perp_y = direction.cos() * half_width;
289
290 format!(
291 "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
292 back_x + perp_x,
293 back_y + perp_y,
294 tip_x,
295 tip_y,
296 back_x - perp_x,
297 back_y - perp_y
298 )
299}
300
301fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
303 (y2 - y1).atan2(x2 - x1)
304}
305
306fn block_has_frame(kind: &BlockKind) -> bool {
307 !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
308}
309
310fn block_is_parallel(kind: &BlockKind) -> bool {
311 matches!(kind, BlockKind::Parallel)
312}
313
314fn parallel_needs_gap(items: &[Item]) -> bool {
315 items.iter().any(|item| matches!(item, Item::Block { .. }))
316}
317
318fn text_char_weight(c: char) -> f64 {
319 if c.is_ascii() {
320 if c.is_uppercase() {
321 0.7
322 } else {
323 0.5
324 }
325 } else {
326 1.0 }
328}
329
330fn participant_char_width(c: char) -> f64 {
333 match c {
334 'W' | 'w' => 14.0,
336 'M' | 'm' => 12.5,
337 '@' | '%' => 14.0,
338 'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'H' | 'K' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'X' | 'Y' | 'Z' => 12.0,
340 'F' | 'I' | 'J' | 'L' => 7.0,
342 'o' | 'e' | 'a' | 'n' | 'u' | 'v' | 'x' | 'z' | 'b' | 'd' | 'g' | 'h' | 'k' | 'p' | 'q' | 's' | 'c' | 'y' => 8.5,
344 'i' | 'j' | 'l' => 4.0,
346 't' | 'f' | 'r' => 6.0,
347 ':' => 6.5,
349 '-' | '_' => 7.0,
350 '[' | ']' | '(' | ')' | '{' | '}' => 7.0,
351 '.' | ',' | '\'' | '`' | ';' => 4.0,
352 ' ' => 5.0,
353 '0'..='9' => 9.0,
355 _ if c.is_ascii() => 8.5,
357 _ => 14.0,
359 }
360}
361
362fn calculate_participant_width(name: &str, min_width: f64) -> f64 {
364 let lines: Vec<&str> = name.split("\\n").collect();
365 let max_line_width = lines
366 .iter()
367 .map(|line| line.chars().map(participant_char_width).sum::<f64>())
368 .fold(0.0_f64, |a, b| a.max(b));
369
370 let padding = 50.0;
372
373 (max_line_width + padding).max(min_width)
374}
375
376fn max_weighted_line(text: &str) -> f64 {
377 text.split("\\n")
378 .map(|line| line.chars().map(text_char_weight).sum::<f64>())
379 .fold(0.0_f64, |a, b| a.max(b))
380}
381
382fn estimate_text_width(text: &str, font_size: f64) -> f64 {
384 let weighted = max_weighted_line(text);
385 weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
386}
387
388fn estimate_message_width(text: &str, font_size: f64) -> f64 {
389 let weighted = max_weighted_line(text);
390 weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
391}
392
393fn block_tab_width(kind: &str) -> f64 {
394 (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
395}
396
397fn calculate_note_width(text: &str, _config: &Config) -> f64 {
399 let lines: Vec<&str> = text.split("\\n").collect();
400 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
401 let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
402 (NOTE_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH)
403}
404
405fn calculate_right_margin(
407 participants: &[Participant],
408 items: &[Item],
409 config: &Config,
410) -> f64 {
411 let rightmost_id = match participants.last() {
412 Some(p) => p.id().to_string(),
413 None => return config.right_margin,
414 };
415 let mut max_right_note_width: f64 = 0.0;
416
417 fn process_items_for_right_notes(
418 items: &[Item],
419 rightmost_id: &str,
420 max_width: &mut f64,
421 config: &Config,
422 ) {
423 for item in items {
424 match item {
425 Item::Note {
426 position: NotePosition::Right,
427 participants,
428 text,
429 } => {
430 if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
432 let note_width = calculate_note_width(text, config);
433 if note_width > *max_width {
434 *max_width = note_width;
435 }
436 }
437 }
438 Item::Block {
439 items, else_items, ..
440 } => {
441 process_items_for_right_notes(items, rightmost_id, max_width, config);
442 if let Some(else_items) = else_items {
443 process_items_for_right_notes(else_items, rightmost_id, max_width, config);
444 }
445 }
446 _ => {}
447 }
448 }
449 }
450
451 process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
452
453 if max_right_note_width > 0.0 {
455 (max_right_note_width + NOTE_MARGIN).max(config.right_margin)
456 } else {
457 config.right_margin
458 }
459}
460
461fn calculate_left_margin(
463 participants: &[Participant],
464 items: &[Item],
465 config: &Config,
466) -> f64 {
467 let leftmost_id = match participants.first() {
468 Some(p) => p.id().to_string(),
469 None => return config.padding,
470 };
471 let mut max_left_note_width: f64 = 0.0;
472
473 fn process_items_for_left_notes(
474 items: &[Item],
475 leftmost_id: &str,
476 max_width: &mut f64,
477 config: &Config,
478 ) {
479 for item in items {
480 match item {
481 Item::Note {
482 position: NotePosition::Left,
483 participants,
484 text,
485 } => {
486 if participants.first().map(|s| s.as_str()) == Some(leftmost_id) {
488 let note_width = calculate_note_width(text, config);
489 if note_width > *max_width {
490 *max_width = note_width;
491 }
492 }
493 }
494 Item::Block {
495 items, else_items, ..
496 } => {
497 process_items_for_left_notes(items, leftmost_id, max_width, config);
498 if let Some(else_items) = else_items {
499 process_items_for_left_notes(else_items, leftmost_id, max_width, config);
500 }
501 }
502 _ => {}
503 }
504 }
505 }
506
507 process_items_for_left_notes(items, &leftmost_id, &mut max_left_note_width, config);
508
509 if max_left_note_width > 0.0 {
511 (max_left_note_width + NOTE_MARGIN).max(config.padding)
512 } else {
513 config.padding
514 }
515}
516
517fn calculate_participant_gaps(
519 participants: &[Participant],
520 items: &[Item],
521 config: &Config,
522) -> Vec<f64> {
523 if participants.len() <= 1 {
524 return vec![];
525 }
526
527 let mut participant_index: HashMap<String, usize> = HashMap::new();
529 for (i, p) in participants.iter().enumerate() {
530 participant_index.insert(p.id().to_string(), i);
531 }
532
533 let min_gap = config.participant_gap;
536 let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
537
538 fn process_items(
540 items: &[Item],
541 participant_index: &HashMap<String, usize>,
542 gaps: &mut Vec<f64>,
543 config: &Config,
544 ) {
545 for item in items {
546 match item {
547 Item::Message { from, to, text, arrow, .. } => {
548 if let (Some(&from_idx), Some(&to_idx)) =
549 (participant_index.get(from), participant_index.get(to))
550 {
551 if from_idx != to_idx {
552 let (min_idx, max_idx) = if from_idx < to_idx {
553 (from_idx, to_idx)
554 } else {
555 (to_idx, from_idx)
556 };
557
558 let text_width = estimate_message_width(text, config.font_size);
559
560 let delay_extra = arrow.delay.map(|d| d as f64 * 86.4).unwrap_or(0.0);
563
564 let gap_count = (max_idx - min_idx) as f64;
566 let needed_gap = if gap_count == 1.0 {
567 text_width - 36.0 + delay_extra
569 } else {
570 text_width / gap_count - 20.0 + delay_extra
572 };
573
574 for gap_idx in min_idx..max_idx {
576 if needed_gap > gaps[gap_idx] {
577 gaps[gap_idx] = needed_gap;
578 }
579 }
580 }
581 }
582 }
583 Item::Note {
584 position,
585 participants: note_participants,
586 text,
587 } => {
588 let note_width = calculate_note_width(text, config);
590
591 if let Some(participant) = note_participants.first() {
592 if let Some(&idx) = participant_index.get(participant) {
593 match position {
594 NotePosition::Left => {
595 if idx > 0 {
597 let needed_gap = note_width + NOTE_MARGIN * 2.0;
599 if needed_gap > gaps[idx - 1] {
600 gaps[idx - 1] = needed_gap;
601 }
602 }
603 }
604 NotePosition::Right => {
605 if idx < gaps.len() {
607 let needed_gap = note_width + NOTE_MARGIN * 2.0;
608 if needed_gap > gaps[idx] {
609 gaps[idx] = needed_gap;
610 }
611 }
612 }
613 NotePosition::Over => {
614 }
617 }
618 }
619 }
620 }
621 Item::Block {
622 items, else_items, ..
623 } => {
624 process_items(items, participant_index, gaps, config);
625 if let Some(else_items) = else_items {
626 process_items(else_items, participant_index, gaps, config);
627 }
628 }
629 _ => {}
630 }
631 }
632 }
633
634 process_items(items, &participant_index, &mut gaps, config);
635
636 let max_gap = 645.0;
642 for gap in &mut gaps {
643 if *gap > max_gap {
644 *gap = max_gap;
645 }
646 }
647
648 gaps
649}
650
651impl RenderState {
652 fn new(
653 config: Config,
654 participants: Vec<Participant>,
655 items: &[Item],
656 has_title: bool,
657 footer_style: FooterStyle,
658 ) -> Self {
659 let mut config = config;
660 let mut required_header_height = config.header_height;
665 for p in &participants {
666 let lines = p.name.split("\\n").count();
667 let needed = match p.kind {
668 ParticipantKind::Participant => {
669 if lines <= 1 {
671 46.0
672 } else {
673 108.0 }
675 }
676 ParticipantKind::Actor => {
677 if lines <= 1 {
680 85.0
681 } else {
682 108.0
683 }
684 }
685 };
686 if needed > required_header_height {
687 required_header_height = needed;
688 }
689 }
690 if required_header_height > config.header_height {
691 config.header_height = required_header_height;
692 }
693 let mut participant_widths: HashMap<String, f64> = HashMap::new();
696 let min_width = config.participant_width;
697
698 for p in &participants {
699 let width = calculate_participant_width(&p.name, min_width);
700 participant_widths.insert(p.id().to_string(), width);
701 }
702
703 let gaps = calculate_participant_gaps(&participants, items, &config);
704
705 let left_margin = calculate_left_margin(&participants, items, &config);
707 let right_margin = calculate_right_margin(&participants, items, &config);
709
710 let mut participant_x = HashMap::new();
711 let first_width = participants
712 .first()
713 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
714 .unwrap_or(min_width);
715 let mut current_x = config.padding + left_margin + first_width / 2.0;
716
717 for (i, p) in participants.iter().enumerate() {
718 participant_x.insert(p.id().to_string(), current_x);
719 if i < gaps.len() {
720 let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
721 let next_p = participants.get(i + 1);
722 let next_width = next_p
723 .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
724 .unwrap_or(min_width);
725
726 let current_is_actor = p.kind == ParticipantKind::Actor;
729 let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
730
731 let actor_gap_reduction = 0.0;
734 let _ = (current_is_actor, next_is_actor); let calculated_gap = gaps[i] - actor_gap_reduction;
739
740 let half_widths = (current_width + next_width) / 2.0;
743 let neither_is_actor = !current_is_actor && !next_is_actor;
744
745 let either_is_actor = current_is_actor || next_is_actor;
746 let edge_padding = if calculated_gap > 500.0 {
747 10.0
749 } else if either_is_actor && calculated_gap > 130.0 {
750 33.0
752 } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
753 90.0
755 } else if calculated_gap > 130.0 {
756 49.0
758 } else if calculated_gap > config.participant_gap {
759 25.0
761 } else {
762 let max_width = current_width.max(next_width);
764 let min_width_val = current_width.min(next_width);
765 let width_diff = max_width - min_width_val;
766
767 if max_width > 160.0 && min_width_val > 160.0 {
768 1.8
771 } else if max_width > 160.0 && min_width_val > 140.0 {
772 -7.0
775 } else if max_width > 160.0 && min_width_val < 110.0 {
776 11.3
779 } else if max_width > 160.0 && width_diff > 45.0 {
780 -6.0
783 } else if min_width_val < 115.0 {
784 10.0
787 } else {
788 11.0
790 }
791 };
792
793 let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
794 let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
795 current_x += actual_gap;
796 }
797 }
798
799 let last_width = participants
800 .last()
801 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
802 .unwrap_or(min_width);
803 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
804
805 Self {
806 config,
807 participants,
808 participant_x,
809 participant_widths,
810 current_y: 0.0,
811 activations: HashMap::new(),
812 autonumber: None,
813 destroyed: HashMap::new(),
814 has_title,
815 total_width,
816 block_backgrounds: Vec::new(),
817 block_labels: Vec::new(),
818 footer_style,
819 else_return_pending: Vec::new(),
820 serial_first_row_pending: Vec::new(),
821 parallel_depth: 0,
822 message_label_boxes: Vec::new(),
823 }
824 }
825
826 fn get_participant_width(&self, name: &str) -> f64 {
827 *self
828 .participant_widths
829 .get(name)
830 .unwrap_or(&self.config.participant_width)
831 }
832
833 fn get_x(&self, name: &str) -> f64 {
834 *self.participant_x.get(name).unwrap_or(&0.0)
835 }
836
837 fn push_else_return_pending(&mut self) {
838 self.else_return_pending.push(true);
839 }
840
841 fn pop_else_return_pending(&mut self) {
842 self.else_return_pending.pop();
843 }
844
845 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
846 if let Some(pending) = self.else_return_pending.last_mut() {
847 if *pending && matches!(arrow.line, LineStyle::Dashed) {
848 *pending = false;
850 }
851 }
852 }
853
854 fn push_serial_first_row_pending(&mut self) {
855 self.serial_first_row_pending.push(true);
856 }
857
858 fn pop_serial_first_row_pending(&mut self) {
859 self.serial_first_row_pending.pop();
860 }
861
862 fn apply_serial_first_row_gap(&mut self) {
863 if let Some(pending) = self.serial_first_row_pending.last_mut() {
864 if *pending {
865 self.current_y += serial_first_row_gap(self.parallel_depth);
866 *pending = false;
867 }
868 }
869 }
870
871 fn reserve_message_label(
872 &mut self,
873 x_min: f64,
874 x_max: f64,
875 mut y_min: f64,
876 mut y_max: f64,
877 step: f64,
878 ) -> f64 {
879 let mut offset = 0.0;
880 let mut attempts = 0;
881 while self
882 .message_label_boxes
883 .iter()
884 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
885 && attempts < 20
886 {
887 y_min += step;
888 y_max += step;
889 offset += step;
890 attempts += 1;
891 }
892 self.message_label_boxes.push(LabelBox {
893 x_min,
894 x_max,
895 y_min,
896 y_max,
897 });
898 offset
899 }
900
901 fn push_parallel(&mut self) {
902 self.parallel_depth += 1;
903 }
904
905 fn pop_parallel(&mut self) {
906 if self.parallel_depth > 0 {
907 self.parallel_depth -= 1;
908 }
909 }
910
911 fn is_participant_active_at(&self, participant: &str, y: f64) -> bool {
913 if let Some(acts) = self.activations.get(participant) {
914 acts.iter().any(|(start_y, end_y)| {
915 *start_y <= y && end_y.map_or(true, |end| y <= end)
916 })
917 } else {
918 false
919 }
920 }
921
922 fn get_arrow_start_x(&self, participant: &str, y: f64, going_right: bool) -> f64 {
924 let x = self.get_x(participant);
925 if self.is_participant_active_at(participant, y) {
926 let half_width = self.config.activation_width / 2.0;
927 if going_right {
928 x + half_width } else {
930 x - half_width }
932 } else {
933 x
934 }
935 }
936
937 fn get_arrow_end_x(&self, participant: &str, y: f64, coming_from_right: bool) -> f64 {
939 let x = self.get_x(participant);
940 if self.is_participant_active_at(participant, y) {
941 let half_width = self.config.activation_width / 2.0;
942 if coming_from_right {
943 x + half_width } else {
945 x - half_width }
947 } else {
948 x
949 }
950 }
951
952 fn diagram_width(&self) -> f64 {
953 self.total_width
954 }
955
956 fn leftmost_x(&self) -> f64 {
958 self.participants
959 .first()
960 .map(|p| self.get_x(p.id()))
961 .unwrap_or(self.config.padding)
962 }
963
964 fn rightmost_x(&self) -> f64 {
966 self.participants
967 .last()
968 .map(|p| self.get_x(p.id()))
969 .unwrap_or(self.total_width - self.config.padding)
970 }
971
972 fn block_left(&self) -> f64 {
974 let leftmost_width = self
975 .participants
976 .first()
977 .map(|p| self.get_participant_width(p.id()))
978 .unwrap_or(self.config.participant_width);
979 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
980 }
981
982 fn block_right(&self) -> f64 {
984 let rightmost_width = self
985 .participants
986 .last()
987 .map(|p| self.get_participant_width(p.id()))
988 .unwrap_or(self.config.participant_width);
989 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
990 }
991
992 fn header_top(&self) -> f64 {
993 if self.has_title {
994 self.config.padding + self.config.title_height
995 } else {
996 self.config.padding
997 }
998 }
999
1000 fn content_start(&self) -> f64 {
1001 self.header_top() + self.config.header_height + self.config.row_height
1004 }
1005
1006 fn next_number(&mut self) -> Option<u32> {
1007 self.autonumber.map(|n| {
1008 self.autonumber = Some(n + 1);
1009 n
1010 })
1011 }
1012
1013 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1015 self.block_backgrounds.push(BlockBackground {
1016 x,
1017 y,
1018 width,
1019 height,
1020 });
1021 }
1022
1023 fn add_block_label(
1025 &mut self,
1026 x1: f64,
1027 start_y: f64,
1028 end_y: f64,
1029 x2: f64,
1030 kind: &str,
1031 label: &str,
1032 else_y: Option<f64>,
1033 ) {
1034 self.block_labels.push(BlockLabel {
1035 x1,
1036 start_y,
1037 end_y,
1038 x2,
1039 kind: kind.to_string(),
1040 label: label.to_string(),
1041 else_y,
1042 });
1043 }
1044}
1045
1046fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1048 let mut min_left: Option<f64> = None;
1049 let mut max_right: Option<f64> = None;
1050 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1051 let mut includes_leftmost = false;
1052
1053 fn update_bounds(
1054 participant: &str,
1055 state: &RenderState,
1056 min_left: &mut Option<f64>,
1057 max_right: &mut Option<f64>,
1058 includes_leftmost: &mut bool,
1059 leftmost_id: &str,
1060 ) {
1061 let x = state.get_x(participant);
1062 if x > 0.0 {
1063 let width = state.get_participant_width(participant);
1064 let left = x - width / 2.0;
1065 let right = x + width / 2.0;
1066 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1067 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1068 if participant == leftmost_id {
1069 *includes_leftmost = true;
1070 }
1071 }
1072 }
1073
1074 fn process_items(
1075 items: &[Item],
1076 state: &RenderState,
1077 min_left: &mut Option<f64>,
1078 max_right: &mut Option<f64>,
1079 includes_leftmost: &mut bool,
1080 leftmost_id: &str,
1081 ) {
1082 for item in items {
1083 match item {
1084 Item::Message { from, to, .. } => {
1085 update_bounds(
1086 from,
1087 state,
1088 min_left,
1089 max_right,
1090 includes_leftmost,
1091 leftmost_id,
1092 );
1093 update_bounds(
1094 to,
1095 state,
1096 min_left,
1097 max_right,
1098 includes_leftmost,
1099 leftmost_id,
1100 );
1101 }
1102 Item::Note { participants, .. } => {
1103 for p in participants {
1104 update_bounds(
1105 p,
1106 state,
1107 min_left,
1108 max_right,
1109 includes_leftmost,
1110 leftmost_id,
1111 );
1112 }
1113 }
1114 Item::Block {
1115 items, else_items, ..
1116 } => {
1117 process_items(
1118 items,
1119 state,
1120 min_left,
1121 max_right,
1122 includes_leftmost,
1123 leftmost_id,
1124 );
1125 if let Some(else_items) = else_items {
1126 process_items(
1127 else_items,
1128 state,
1129 min_left,
1130 max_right,
1131 includes_leftmost,
1132 leftmost_id,
1133 );
1134 }
1135 }
1136 Item::Activate { participant }
1137 | Item::Deactivate { participant }
1138 | Item::Destroy { participant } => {
1139 update_bounds(
1140 participant,
1141 state,
1142 min_left,
1143 max_right,
1144 includes_leftmost,
1145 leftmost_id,
1146 );
1147 }
1148 _ => {}
1149 }
1150 }
1151 }
1152
1153 process_items(
1154 items,
1155 state,
1156 &mut min_left,
1157 &mut max_right,
1158 &mut includes_leftmost,
1159 leftmost_id,
1160 );
1161
1162 match (min_left, max_right) {
1163 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1164 _ => None,
1165 }
1166}
1167
1168fn calculate_block_bounds_with_label(
1170 items: &[Item],
1171 else_items: Option<&[Item]>,
1172 label: &str,
1173 kind: &str,
1174 depth: usize,
1175 state: &RenderState,
1176) -> (f64, f64) {
1177 let mut all_items: Vec<&Item> = items.iter().collect();
1178 if let Some(else_items) = else_items {
1179 all_items.extend(else_items.iter());
1180 }
1181
1182 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1184
1185 let (base_x1, base_x2) =
1186 if let Some((min_left, max_right, _includes_leftmost)) =
1187 find_involved_participants(&items_slice, state)
1188 {
1189 let margin = state.config.block_margin;
1190 (min_left - margin, max_right + margin)
1191 } else {
1192 (state.block_left(), state.block_right())
1194 };
1195
1196 let pentagon_width = block_tab_width(kind);
1199 let label_font_size = state.config.font_size - 1.0;
1200 let label_padding_x = 6.0;
1201 let condition_width = if label.is_empty() {
1202 0.0
1203 } else {
1204 let condition_text = format!("[{}]", label);
1205 let base_width =
1206 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1207 base_width + label_padding_x * 2.0
1208 };
1209 let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; let current_width = base_x2 - base_x1;
1213 let (mut x1, mut x2) = if current_width < min_label_width {
1214 (base_x1, base_x1 + min_label_width)
1216 } else {
1217 (base_x1, base_x2)
1218 };
1219
1220 let nested_padding = depth as f64 * 20.0;
1222 if nested_padding > 0.0 {
1223 let available = x2 - x1;
1224 let max_padding = ((available - min_label_width) / 2.0).max(0.0);
1225 let inset = nested_padding.min(max_padding);
1226 x1 += inset;
1227 x2 -= inset;
1228 }
1229
1230 (x1, x2)
1235}
1236
1237fn collect_block_backgrounds(
1239 state: &mut RenderState,
1240 items: &[Item],
1241 depth: usize,
1242 active_activation_count: &mut usize,
1243) {
1244 for item in items {
1245 match item {
1246 Item::Message {
1247 text,
1248 from,
1249 to,
1250 arrow,
1251 activate,
1252 deactivate,
1253 create,
1254 ..
1255 } => {
1256 state.apply_else_return_gap(arrow);
1257 let is_self = from == to;
1258 let lines: Vec<&str> = text.split("\\n").collect();
1259 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1260
1261 if is_self {
1262 state.current_y -= SELF_MESSAGE_PRE_GAP_REDUCTION;
1264 let spacing = self_message_spacing(&state.config, lines.len());
1265 state.current_y += spacing;
1266 } else {
1267 let spacing_line_height = message_spacing_line_height(&state.config);
1269 let extra_height = if lines.len() > 1 {
1270 (lines.len() - 1) as f64 * spacing_line_height
1271 } else {
1272 0.0
1273 };
1274 if lines.len() > 1 {
1275 state.current_y += extra_height;
1276 }
1277 state.current_y += state.config.row_height + delay_offset;
1278 }
1279
1280 if *create {
1281 state.current_y += CREATE_MESSAGE_SPACING;
1282 }
1283
1284 state.apply_serial_first_row_gap();
1285 if *activate {
1286 *active_activation_count += 1;
1287 }
1288 if *deactivate && *active_activation_count > 0 {
1289 *active_activation_count -= 1;
1290 }
1291 }
1292 Item::Note { text, .. } => {
1293 let lines: Vec<&str> = text.split("\\n").collect();
1294 let line_height = note_line_height(&state.config);
1295 let note_height =
1296 note_padding(&state.config) * 2.0 + lines.len() as f64 * line_height;
1297 state.current_y += note_height.max(state.config.row_height) + ROW_SPACING;
1299 }
1300 Item::State { text, .. } => {
1301 let lines: Vec<&str> = text.split("\\n").collect();
1302 let line_height = state_line_height(&state.config);
1303 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1304 state.current_y += box_height + item_pre_gap(&state.config);
1305 }
1306 Item::Ref { text, .. } => {
1307 let lines: Vec<&str> = text.split("\\n").collect();
1308 let line_height = ref_line_height(&state.config);
1309 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1310 state.current_y += box_height + item_pre_gap(&state.config) + REF_EXTRA_GAP;
1311 }
1312 Item::Description { text } => {
1313 let lines: Vec<&str> = text.split("\\n").collect();
1314 let line_height = state.config.font_size + 4.0;
1315 state.current_y += lines.len() as f64 * line_height + 10.0;
1316 }
1317 Item::Destroy { .. } => {
1318 state.current_y += DESTROY_SPACING;
1319 }
1320 Item::Activate { .. } => {
1321 *active_activation_count += 1;
1322 }
1323 Item::Deactivate { .. } => {
1324 if *active_activation_count > 0 {
1325 *active_activation_count -= 1;
1326 }
1327 }
1328 Item::Block {
1329 kind,
1330 label,
1331 items,
1332 else_items,
1333 } => {
1334 if block_is_parallel(kind) {
1335 state.push_parallel();
1336 let start_y = state.current_y;
1337 let mut max_end_y = start_y;
1338 let start_activation_count = *active_activation_count;
1339 for item in items {
1340 state.current_y = start_y;
1341 *active_activation_count = start_activation_count;
1342 collect_block_backgrounds(
1343 state,
1344 std::slice::from_ref(item),
1345 depth,
1346 active_activation_count,
1347 );
1348 if state.current_y > max_end_y {
1349 max_end_y = state.current_y;
1350 }
1351 }
1352 *active_activation_count = start_activation_count;
1353 let gap = if parallel_needs_gap(items) {
1354 BLOCK_GAP
1355 } else {
1356 0.0
1357 };
1358 state.current_y = max_end_y + gap;
1359 state.pop_parallel();
1360 continue;
1361 }
1362
1363 if matches!(kind, BlockKind::Serial) {
1364 state.push_serial_first_row_pending();
1365 collect_block_backgrounds(state, items, depth, active_activation_count);
1366 if let Some(else_items) = else_items {
1367 collect_block_backgrounds(
1368 state,
1369 else_items,
1370 depth,
1371 active_activation_count,
1372 );
1373 }
1374 state.pop_serial_first_row_pending();
1375 continue;
1376 }
1377
1378 if !block_has_frame(kind) {
1379 collect_block_backgrounds(state, items, depth, active_activation_count);
1380 if let Some(else_items) = else_items {
1381 collect_block_backgrounds(
1382 state,
1383 else_items,
1384 depth,
1385 active_activation_count,
1386 );
1387 }
1388 continue;
1389 }
1390
1391 let start_y = state.current_y;
1392 let frame_shift = block_frame_shift(depth);
1393 let frame_start_y = start_y - frame_shift;
1394
1395 let (x1, x2) = calculate_block_bounds_with_label(
1397 items,
1398 else_items.as_deref(),
1399 label,
1400 kind.as_str(),
1401 depth,
1402 state,
1403 );
1404
1405 state.current_y += block_header_space(&state.config, depth);
1406 collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1407
1408 let else_y = if else_items.is_some() {
1410 state.current_y += block_else_before(&state.config, depth);
1411 Some(state.current_y)
1412 } else {
1413 None
1414 };
1415
1416 if let Some(else_items) = else_items {
1417 state.push_else_return_pending();
1418 state.current_y += block_else_after(&state.config, depth);
1420 collect_block_backgrounds(
1421 state,
1422 else_items,
1423 depth + 1,
1424 active_activation_count,
1425 );
1426 state.pop_else_return_pending();
1427 }
1428
1429 let end_y = state.current_y + block_footer_padding(&state.config, depth);
1432 let frame_end_y = end_y - frame_shift;
1433 state.current_y = end_y + state.config.row_height;
1434
1435 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1437 state.add_block_label(
1439 x1,
1440 frame_start_y,
1441 frame_end_y,
1442 x2,
1443 kind.as_str(),
1444 label,
1445 else_y,
1446 );
1447 }
1448 _ => {}
1449 }
1450 }
1451}
1452
1453fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1455 let theme = &state.config.theme;
1456 for bg in &state.block_backgrounds {
1457 writeln!(
1458 svg,
1459 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1460 x = bg.x,
1461 y = bg.y,
1462 w = bg.width,
1463 h = bg.height,
1464 fill = theme.block_fill
1465 )
1466 .unwrap();
1467 }
1468}
1469
1470fn render_block_labels(svg: &mut String, state: &RenderState) {
1473 let theme = &state.config.theme;
1474
1475 for bl in &state.block_labels {
1476 let x1 = bl.x1;
1477 let x2 = bl.x2;
1478 let start_y = bl.start_y;
1479 let end_y = bl.end_y;
1480
1481 writeln!(
1483 svg,
1484 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1485 x = x1,
1486 y = start_y,
1487 w = x2 - x1,
1488 h = end_y - start_y
1489 )
1490 .unwrap();
1491
1492 let label_text = &bl.kind;
1494 let label_width = block_tab_width(label_text);
1495 let label_height = BLOCK_LABEL_HEIGHT;
1496 let label_text_offset = 16.0;
1497 let notch_size = 5.0;
1498
1499 let pentagon_path = format!(
1501 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1502 x1 = x1,
1503 y1 = start_y,
1504 x2 = x1 + label_width,
1505 y2 = start_y + label_height - notch_size,
1506 x3 = x1 + label_width - notch_size,
1507 y3 = start_y + label_height
1508 );
1509
1510 writeln!(
1511 svg,
1512 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1513 path = pentagon_path,
1514 fill = theme.block_label_fill,
1515 stroke = theme.block_stroke
1516 )
1517 .unwrap();
1518
1519 writeln!(
1521 svg,
1522 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1523 x = x1 + 5.0,
1524 y = start_y + label_text_offset,
1525 kind = label_text
1526 )
1527 .unwrap();
1528
1529 if !bl.label.is_empty() {
1531 let condition_text = format!("[{}]", bl.label);
1532 let text_x = x1 + label_width + 8.0;
1533 let text_y = start_y + label_text_offset;
1534
1535 writeln!(
1536 svg,
1537 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1538 x = text_x,
1539 y = text_y,
1540 label = escape_xml(&condition_text)
1541 )
1542 .unwrap();
1543 }
1544
1545 if let Some(else_y) = bl.else_y {
1547 writeln!(
1548 svg,
1549 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1550 x1 = x1,
1551 y = else_y,
1552 x2 = x2,
1553 c = theme.block_stroke
1554 )
1555 .unwrap();
1556 }
1557 }
1558}
1559
1560pub fn render(diagram: &Diagram) -> String {
1562 render_with_config(diagram, Config::default())
1563}
1564
1565pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1567 let participants = diagram.participants();
1568 let has_title = diagram.title.is_some();
1569 let footer_style = diagram.options.footer;
1570 let mut state = RenderState::new(
1571 config,
1572 participants,
1573 &diagram.items,
1574 has_title,
1575 footer_style,
1576 );
1577 let mut svg = String::new();
1578
1579 let content_height = calculate_height(&diagram.items, &state.config, 0);
1581 let title_space = if has_title {
1582 state.config.title_height
1583 } else {
1584 0.0
1585 };
1586 let footer_space = match footer_style {
1587 FooterStyle::Box => state.config.header_height,
1588 FooterStyle::Bar | FooterStyle::None => 0.0,
1589 };
1590 let footer_label_extra = match footer_style {
1591 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1592 FooterStyle::Bar | FooterStyle::None => 0.0,
1593 };
1594 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1596 + title_space
1597 + state.config.header_height
1598 + content_height
1599 + footer_margin
1600 + footer_space;
1601 let total_height = base_total_height + footer_label_extra;
1602 let total_width = state.diagram_width();
1603
1604 writeln!(
1606 &mut svg,
1607 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1608 w = total_width,
1609 h = total_height
1610 )
1611 .unwrap();
1612
1613 let theme = &state.config.theme;
1615 let lifeline_dash = match theme.lifeline_style {
1616 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1617 LifelineStyle::Solid => "",
1618 };
1619
1620 svg.push_str("<defs>\n");
1621 svg.push_str("<style>\n");
1622 writeln!(
1623 &mut svg,
1624 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1625 fill = theme.participant_fill,
1626 stroke = theme.participant_stroke
1627 )
1628 .unwrap();
1629 writeln!(
1630 &mut svg,
1631 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1632 f = theme.font_family,
1633 s = state.config.font_size,
1634 c = theme.participant_text
1635 )
1636 .unwrap();
1637 writeln!(
1638 &mut svg,
1639 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1640 c = theme.lifeline_color,
1641 dash = lifeline_dash
1642 )
1643 .unwrap();
1644 writeln!(
1645 &mut svg,
1646 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1647 c = theme.message_color
1648 )
1649 .unwrap();
1650 writeln!(
1651 &mut svg,
1652 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1653 c = theme.message_color
1654 )
1655 .unwrap();
1656 writeln!(
1657 &mut svg,
1658 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1659 f = theme.font_family,
1660 s = state.config.font_size,
1661 c = theme.message_text_color
1662 )
1663 .unwrap();
1664 writeln!(
1665 &mut svg,
1666 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1667 fill = theme.note_fill,
1668 stroke = theme.note_stroke
1669 )
1670 .unwrap();
1671 writeln!(
1672 &mut svg,
1673 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1674 f = theme.font_family,
1675 s = state.config.font_size - 1.0,
1676 c = theme.note_text_color
1677 )
1678 .unwrap();
1679 writeln!(
1680 &mut svg,
1681 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1682 c = theme.block_stroke
1683 )
1684 .unwrap();
1685 writeln!(
1686 &mut svg,
1687 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1688 f = theme.font_family,
1689 s = state.config.font_size - 1.0,
1690 c = theme.message_text_color
1691 )
1692 .unwrap();
1693 writeln!(
1694 &mut svg,
1695 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1696 fill = theme.activation_fill,
1697 stroke = theme.activation_stroke
1698 )
1699 .unwrap();
1700 writeln!(
1701 &mut svg,
1702 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1703 fill = theme.actor_fill,
1704 stroke = theme.actor_stroke
1705 )
1706 .unwrap();
1707 writeln!(
1708 &mut svg,
1709 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1710 c = theme.actor_stroke
1711 )
1712 .unwrap();
1713 writeln!(
1714 &mut svg,
1715 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1716 f = theme.font_family,
1717 s = state.config.font_size + 4.0,
1718 c = theme.message_text_color
1719 )
1720 .unwrap();
1721 writeln!(
1723 &mut svg,
1724 ".arrowhead {{ fill: {c}; stroke: none; }}",
1725 c = theme.message_color
1726 )
1727 .unwrap();
1728 writeln!(
1729 &mut svg,
1730 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1731 c = theme.message_color
1732 )
1733 .unwrap();
1734 svg.push_str("</style>\n");
1735 svg.push_str("</defs>\n");
1736
1737 writeln!(
1739 &mut svg,
1740 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1741 bg = theme.background
1742 )
1743 .unwrap();
1744
1745 if let Some(title) = &diagram.title {
1747 let title_y = state.config.padding + state.config.font_size + 7.36; writeln!(
1749 &mut svg,
1750 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1751 x = total_width / 2.0,
1752 y = title_y,
1753 t = escape_xml(title)
1754 )
1755 .unwrap();
1756 }
1757
1758 let header_y = state.header_top();
1760 let footer_y = match footer_style {
1761 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1762 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1763 };
1764
1765 state.current_y = state.content_start();
1767 let mut active_activation_count = 0;
1768 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1769
1770 render_block_backgrounds(&mut svg, &state);
1772
1773 state.current_y = state.content_start();
1775
1776 let lifeline_start = header_y + state.config.header_height;
1778 let lifeline_end = footer_y;
1779
1780 for p in &state.participants {
1781 let x = state.get_x(p.id());
1782 writeln!(
1783 &mut svg,
1784 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1785 x = x,
1786 y1 = lifeline_start,
1787 y2 = lifeline_end
1788 )
1789 .unwrap();
1790 }
1791
1792 render_participant_headers(&mut svg, &state, header_y);
1794
1795 state.current_y = state.content_start();
1797 render_items(&mut svg, &mut state, &diagram.items, 0);
1798
1799 render_activations(&mut svg, &mut state, footer_y);
1801
1802 render_block_labels(&mut svg, &state);
1804
1805 match state.footer_style {
1807 FooterStyle::Box => {
1808 render_participant_headers(&mut svg, &state, footer_y);
1809 }
1810 FooterStyle::Bar => {
1811 let left = state.leftmost_x()
1813 - state.get_participant_width(
1814 state.participants.first().map(|p| p.id()).unwrap_or(""),
1815 ) / 2.0;
1816 let right = state.rightmost_x()
1817 + state
1818 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1819 / 2.0;
1820 writeln!(
1821 &mut svg,
1822 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1823 x1 = left,
1824 y = footer_y,
1825 x2 = right,
1826 c = state.config.theme.lifeline_color
1827 )
1828 .unwrap();
1829 }
1830 FooterStyle::None => {
1831 }
1833 }
1834
1835 svg.push_str("</svg>\n");
1836 svg
1837}
1838
1839fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1840 fn inner(
1841 items: &[Item],
1842 config: &Config,
1843 depth: usize,
1844 else_pending: &mut Vec<bool>,
1845 serial_pending: &mut Vec<bool>,
1846 active_activation_count: &mut usize,
1847 parallel_depth: &mut usize,
1848 ) -> f64 {
1849 let mut height = 0.0;
1850 let line_height = config.font_size + 4.0;
1851 for item in items {
1852 match item {
1853 Item::Message {
1854 from,
1855 to,
1856 text,
1857 arrow,
1858 create,
1859 activate,
1860 deactivate,
1861 ..
1862 } => {
1863 if let Some(pending) = else_pending.last_mut() {
1864 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1865 *pending = false;
1866 }
1867 }
1868 let is_self = from == to;
1869 let lines = text.split("\\n").count();
1870 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1871 if is_self {
1872 let spacing = self_message_spacing(config, lines);
1873 height += spacing;
1874 } else {
1875 let spacing_line_height = message_spacing_line_height(config);
1876 height += config.row_height
1877 + (lines.saturating_sub(1)) as f64 * spacing_line_height
1878 + delay_offset;
1879 }
1880 if *create {
1881 height += CREATE_MESSAGE_SPACING;
1882 }
1883 if let Some(pending) = serial_pending.last_mut() {
1884 if *pending {
1885 height += serial_first_row_gap(*parallel_depth);
1886 *pending = false;
1887 }
1888 }
1889 if *activate {
1890 *active_activation_count += 1;
1891 }
1892 if *deactivate && *active_activation_count > 0 {
1893 *active_activation_count -= 1;
1894 }
1895 }
1896 Item::Note { text, .. } => {
1897 let lines = text.split("\\n").count();
1898 let note_height =
1899 note_padding(config) * 2.0 + lines as f64 * note_line_height(config);
1900 height += note_height.max(config.row_height) + ROW_SPACING;
1902 }
1903 Item::State { text, .. } => {
1904 let lines = text.split("\\n").count();
1905 let box_height =
1906 config.note_padding * 2.0 + lines as f64 * state_line_height(config);
1907 height += box_height + item_pre_gap(config);
1908 }
1909 Item::Ref { text, .. } => {
1910 let lines = text.split("\\n").count();
1911 let box_height =
1912 config.note_padding * 2.0 + lines as f64 * ref_line_height(config);
1913 height += box_height + item_pre_gap(config) + REF_EXTRA_GAP;
1914 }
1915 Item::Description { text } => {
1916 let lines = text.split("\\n").count();
1917 height += lines as f64 * line_height + 10.0;
1918 }
1919 Item::Block {
1920 kind,
1921 items,
1922 else_items,
1923 ..
1924 } => {
1925 if block_is_parallel(kind) {
1926 let mut max_branch_height = 0.0;
1927 let base_activation_count = *active_activation_count;
1928 *parallel_depth += 1;
1929 for item in items {
1930 *active_activation_count = base_activation_count;
1931 let branch_height = inner(
1932 std::slice::from_ref(item),
1933 config,
1934 depth,
1935 else_pending,
1936 serial_pending,
1937 active_activation_count,
1938 parallel_depth,
1939 );
1940 if branch_height > max_branch_height {
1941 max_branch_height = branch_height;
1942 }
1943 }
1944 *active_activation_count = base_activation_count;
1945 if *parallel_depth > 0 {
1946 *parallel_depth -= 1;
1947 }
1948 let gap = if parallel_needs_gap(items) {
1949 BLOCK_GAP
1950 } else {
1951 0.0
1952 };
1953 height += max_branch_height + gap;
1954 continue;
1955 }
1956
1957 if matches!(kind, BlockKind::Serial) {
1958 serial_pending.push(true);
1959 height += inner(
1960 items,
1961 config,
1962 depth,
1963 else_pending,
1964 serial_pending,
1965 active_activation_count,
1966 parallel_depth,
1967 );
1968 if let Some(else_items) = else_items {
1969 height += inner(
1970 else_items,
1971 config,
1972 depth,
1973 else_pending,
1974 serial_pending,
1975 active_activation_count,
1976 parallel_depth,
1977 );
1978 }
1979 serial_pending.pop();
1980 } else if !block_has_frame(kind) {
1981 height += inner(
1982 items,
1983 config,
1984 depth,
1985 else_pending,
1986 serial_pending,
1987 active_activation_count,
1988 parallel_depth,
1989 );
1990 if let Some(else_items) = else_items {
1991 height += inner(
1992 else_items,
1993 config,
1994 depth,
1995 else_pending,
1996 serial_pending,
1997 active_activation_count,
1998 parallel_depth,
1999 );
2000 }
2001 } else {
2002 height += block_header_space(config, depth);
2003 height += inner(
2004 items,
2005 config,
2006 depth + 1,
2007 else_pending,
2008 serial_pending,
2009 active_activation_count,
2010 parallel_depth,
2011 );
2012 if let Some(else_items) = else_items {
2013 else_pending.push(true);
2014 height += block_else_before(config, depth) + block_else_after(config, depth);
2016 height += inner(
2017 else_items,
2018 config,
2019 depth + 1,
2020 else_pending,
2021 serial_pending,
2022 active_activation_count,
2023 parallel_depth,
2024 );
2025 else_pending.pop();
2026 }
2027 height += block_footer_padding(config, depth) + config.row_height;
2029 }
2030 }
2031 Item::Activate { .. } => {
2032 *active_activation_count += 1;
2033 }
2034 Item::Deactivate { .. } => {
2035 if *active_activation_count > 0 {
2036 *active_activation_count -= 1;
2037 }
2038 }
2039 Item::Destroy { .. } => {
2040 height += DESTROY_SPACING;
2041 }
2042 Item::ParticipantDecl { .. } => {}
2043 Item::Autonumber { .. } => {}
2044 Item::DiagramOption { .. } => {} }
2046 }
2047 height
2048 }
2049
2050 let mut else_pending = Vec::new();
2051 let mut serial_pending = Vec::new();
2052 let mut active_activation_count = 0;
2053 let mut parallel_depth = 0;
2054 inner(
2055 items,
2056 config,
2057 depth,
2058 &mut else_pending,
2059 &mut serial_pending,
2060 &mut active_activation_count,
2061 &mut parallel_depth,
2062 )
2063}
2064
2065fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2066 let shape = state.config.theme.participant_shape;
2067
2068 for p in &state.participants {
2069 let x = state.get_x(p.id());
2070 let p_width = state.get_participant_width(p.id());
2071 let box_x = x - p_width / 2.0;
2072
2073 match p.kind {
2074 ParticipantKind::Participant => {
2075 match shape {
2077 ParticipantShape::Rectangle => {
2078 writeln!(
2079 svg,
2080 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2081 x = box_x,
2082 y = y,
2083 w = p_width,
2084 h = state.config.header_height
2085 )
2086 .unwrap();
2087 }
2088 ParticipantShape::RoundedRect => {
2089 writeln!(
2090 svg,
2091 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2092 x = box_x,
2093 y = y,
2094 w = p_width,
2095 h = state.config.header_height
2096 )
2097 .unwrap();
2098 }
2099 ParticipantShape::Circle => {
2100 let rx = p_width / 2.0 - 5.0;
2102 let ry = state.config.header_height / 2.0 - 2.0;
2103 writeln!(
2104 svg,
2105 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2106 cx = x,
2107 cy = y + state.config.header_height / 2.0,
2108 rx = rx,
2109 ry = ry
2110 )
2111 .unwrap();
2112 }
2113 }
2114 let lines: Vec<&str> = p.name.split("\\n").collect();
2116 if lines.len() == 1 {
2117 writeln!(
2118 svg,
2119 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2120 x = x,
2121 y = y + state.config.header_height / 2.0 + 5.0,
2122 name = escape_xml(&p.name)
2123 )
2124 .unwrap();
2125 } else {
2126 let line_height = state.config.font_size + 2.0;
2127 let total_height = lines.len() as f64 * line_height;
2128 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2129 + line_height * 0.8;
2130 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2131 for (i, line) in lines.iter().enumerate() {
2132 let dy = if i == 0 { start_y } else { line_height };
2133 if i == 0 {
2134 writeln!(
2135 svg,
2136 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2137 x = x,
2138 y = dy,
2139 text = escape_xml(line)
2140 )
2141 .unwrap();
2142 } else {
2143 writeln!(
2144 svg,
2145 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2146 x = x,
2147 dy = dy,
2148 text = escape_xml(line)
2149 )
2150 .unwrap();
2151 }
2152 }
2153 writeln!(svg, "</text>").unwrap();
2154 }
2155 }
2156 ParticipantKind::Actor => {
2157 let head_r = 8.0;
2159 let body_len = 12.0;
2160 let arm_len = 10.0;
2161 let leg_len = 10.0;
2162 let figure_height = 38.0; let fig_top = y + 8.0;
2166 let fig_center_y = fig_top + head_r + body_len / 2.0;
2167 let arm_y = fig_center_y + 2.0;
2168
2169 writeln!(
2171 svg,
2172 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2173 x = x,
2174 cy = fig_center_y - body_len / 2.0 - head_r,
2175 r = head_r
2176 )
2177 .unwrap();
2178 writeln!(
2180 svg,
2181 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2182 x = x,
2183 y1 = fig_center_y - body_len / 2.0,
2184 y2 = fig_center_y + body_len / 2.0
2185 )
2186 .unwrap();
2187 writeln!(
2189 svg,
2190 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2191 x1 = x - arm_len,
2192 y = arm_y,
2193 x2 = x + arm_len
2194 )
2195 .unwrap();
2196 writeln!(
2198 svg,
2199 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2200 x = x,
2201 y1 = fig_center_y + body_len / 2.0,
2202 x2 = x - leg_len * 0.6,
2203 y2 = fig_center_y + body_len / 2.0 + leg_len
2204 )
2205 .unwrap();
2206 writeln!(
2208 svg,
2209 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2210 x = x,
2211 y1 = fig_center_y + body_len / 2.0,
2212 x2 = x + leg_len * 0.6,
2213 y2 = fig_center_y + body_len / 2.0 + leg_len
2214 )
2215 .unwrap();
2216 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2218 let name_start_y = fig_top + figure_height + 5.0;
2219 if name_lines.len() == 1 {
2220 writeln!(
2221 svg,
2222 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2223 x = x,
2224 y = name_start_y + state.config.font_size,
2225 name = escape_xml(&p.name)
2226 )
2227 .unwrap();
2228 } else {
2229 let line_height = state.config.font_size + 2.0;
2231 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2232 for (i, line) in name_lines.iter().enumerate() {
2233 if i == 0 {
2234 writeln!(
2235 svg,
2236 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2237 x = x,
2238 y = name_start_y + state.config.font_size,
2239 text = escape_xml(line)
2240 )
2241 .unwrap();
2242 } else {
2243 writeln!(
2244 svg,
2245 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2246 x = x,
2247 dy = line_height,
2248 text = escape_xml(line)
2249 )
2250 .unwrap();
2251 }
2252 }
2253 writeln!(svg, "</text>").unwrap();
2254 }
2255 }
2256 }
2257 }
2258}
2259
2260fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2261 for item in items {
2262 match item {
2263 Item::Message {
2264 from,
2265 to,
2266 text,
2267 arrow,
2268 activate,
2269 deactivate,
2270 create,
2271 ..
2272 } => {
2273 render_message(
2274 svg,
2275 state,
2276 from,
2277 to,
2278 text,
2279 arrow,
2280 *activate,
2281 *deactivate,
2282 *create,
2283 depth,
2284 );
2285 }
2286 Item::Note {
2287 position,
2288 participants,
2289 text,
2290 } => {
2291 render_note(svg, state, position, participants, text);
2292 }
2293 Item::Block {
2294 kind,
2295 label,
2296 items,
2297 else_items,
2298 } => {
2299 render_block(svg, state, kind, label, items, else_items.as_deref(), depth);
2300 }
2301 Item::Activate { participant } => {
2302 let y = state.current_y;
2303 state
2304 .activations
2305 .entry(participant.clone())
2306 .or_default()
2307 .push((y, None));
2308 }
2309 Item::Deactivate { participant } => {
2310 if let Some(acts) = state.activations.get_mut(participant) {
2311 if let Some(act) = acts.last_mut() {
2312 if act.1.is_none() {
2313 act.1 = Some(state.current_y);
2314 }
2315 }
2316 }
2317 }
2318 Item::Destroy { participant } => {
2319 let destroy_y = state.current_y - state.config.row_height;
2322 state.destroyed.insert(participant.clone(), destroy_y);
2323 let x = state.get_x(participant);
2325 let y = destroy_y;
2326 let size = 15.0; let theme = &state.config.theme;
2328 writeln!(
2329 svg,
2330 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2331 x1 = x - size,
2332 y1 = y - size,
2333 x2 = x + size,
2334 y2 = y + size,
2335 stroke = theme.message_color
2336 )
2337 .unwrap();
2338 writeln!(
2339 svg,
2340 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2341 x1 = x + size,
2342 y1 = y - size,
2343 x2 = x - size,
2344 y2 = y + size,
2345 stroke = theme.message_color
2346 )
2347 .unwrap();
2348 state.current_y += DESTROY_SPACING;
2349 }
2350 Item::Autonumber { enabled, start } => {
2351 if *enabled {
2352 state.autonumber = Some(start.unwrap_or(1));
2353 } else {
2354 state.autonumber = None;
2355 }
2356 }
2357 Item::ParticipantDecl { .. } => {
2358 }
2360 Item::State { participants, text } => {
2361 render_state(svg, state, participants, text);
2362 }
2363 Item::Ref {
2364 participants,
2365 text,
2366 input_from,
2367 input_label,
2368 output_to,
2369 output_label,
2370 } => {
2371 render_ref(
2372 svg,
2373 state,
2374 participants,
2375 text,
2376 input_from.as_deref(),
2377 input_label.as_deref(),
2378 output_to.as_deref(),
2379 output_label.as_deref(),
2380 );
2381 }
2382 Item::DiagramOption { .. } => {
2383 }
2385 Item::Description { text } => {
2386 render_description(svg, state, text);
2387 }
2388 }
2389 }
2390}
2391
2392fn render_message(
2393 svg: &mut String,
2394 state: &mut RenderState,
2395 from: &str,
2396 to: &str,
2397 text: &str,
2398 arrow: &Arrow,
2399 activate: bool,
2400 deactivate: bool,
2401 create: bool,
2402 _depth: usize,
2403) {
2404 let base_x1 = state.get_x(from);
2406 let base_x2 = state.get_x(to);
2407
2408 state.apply_else_return_gap(arrow);
2409
2410 let is_self = from == to;
2411 let line_class = match arrow.line {
2412 LineStyle::Solid => "message",
2413 LineStyle::Dashed => "message-dashed",
2414 };
2415 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2416
2417 let num_prefix = state
2419 .next_number()
2420 .map(|n| format!("{}. ", n))
2421 .unwrap_or_default();
2422
2423 let display_text = format!("{}{}", num_prefix, text);
2425 let lines: Vec<&str> = display_text.split("\\n").collect();
2426 let line_height = state.config.font_size + 4.0;
2427 let extra_height = if !is_self && lines.len() > 1 {
2428 let spacing_line_height = message_spacing_line_height(&state.config);
2429 (lines.len() - 1) as f64 * spacing_line_height
2430 } else {
2431 0.0
2432 };
2433
2434 if !is_self && lines.len() > 1 {
2436 state.current_y += extra_height;
2437 }
2438
2439 if is_self {
2441 state.current_y -= SELF_MESSAGE_PRE_GAP_REDUCTION;
2442 }
2443
2444 let y = state.current_y;
2445 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2446
2447 let going_right = base_x2 > base_x1;
2449 let x1 = state.get_arrow_start_x(from, y, going_right);
2450 let x2 = state.get_arrow_end_x(to, y, !going_right);
2451
2452 writeln!(svg, r#"<g class="message">"#).unwrap();
2454
2455 if is_self {
2456 let loop_width = 40.0;
2458 let text_block_height = lines.len() as f64 * line_height;
2459 let loop_height = text_block_height.max(25.0);
2461 let arrow_end_x = x1;
2462 let arrow_end_y = y + loop_height;
2463 let direction = std::f64::consts::PI;
2465 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2466
2467 writeln!(
2468 svg,
2469 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2470 x1 = x1,
2471 y = y,
2472 x2 = x1 + loop_width,
2473 y2 = y + loop_height,
2474 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2475 cls = line_class
2476 )
2477 .unwrap();
2478
2479 if is_filled {
2481 writeln!(
2482 svg,
2483 r#" <polygon points="{points}" class="arrowhead"/>"#,
2484 points = arrow_points
2485 )
2486 .unwrap();
2487 } else {
2488 writeln!(
2489 svg,
2490 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2491 points = arrow_points
2492 )
2493 .unwrap();
2494 }
2495
2496 let text_x = x1 + loop_width + 5.0;
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 = y + 4.0 + 0.5 * line_height;
2503 let bottom_line_y = y + 4.0 + (lines.len() as f64 - 0.5) * line_height;
2504 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2505 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2506 let label_x_min = text_x;
2507 let label_x_max = text_x + max_width;
2508 let label_offset = if has_label_text {
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 = y + 4.0 + (i as f64 + 0.5) * line_height + label_offset;
2516 writeln!(
2517 svg,
2518 r#" <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2519 x = text_x,
2520 y = line_y,
2521 t = escape_xml(line)
2522 )
2523 .unwrap();
2524 }
2525
2526 writeln!(svg, r#"</g>"#).unwrap();
2528
2529 let spacing = self_message_spacing(&state.config, lines.len());
2530 state.current_y += spacing;
2531 } else {
2532 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2534 let y2 = y + delay_offset;
2535
2536 let text_x = (base_x1 + base_x2) / 2.0;
2538 let text_y = (y + y2) / 2.0 - 6.0; let direction = arrow_direction(x1, y, x2, y2);
2542 let arrow_points = arrowhead_points(x2, y2, direction);
2543
2544 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2546 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2547
2548 writeln!(
2550 svg,
2551 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2552 x1 = x1,
2553 y1 = y,
2554 lx2 = line_end_x,
2555 ly2 = line_end_y,
2556 cls = line_class
2557 )
2558 .unwrap();
2559
2560 if is_filled {
2562 writeln!(
2563 svg,
2564 r#" <polygon points="{points}" class="arrowhead"/>"#,
2565 points = arrow_points
2566 )
2567 .unwrap();
2568 } else {
2569 writeln!(
2570 svg,
2571 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2572 points = arrow_points
2573 )
2574 .unwrap();
2575 }
2576
2577 let max_width = lines
2579 .iter()
2580 .map(|line| estimate_message_width(line, state.config.font_size))
2581 .fold(0.0, f64::max);
2582 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2583 let bottom_line_y = text_y;
2584 let label_offset = if has_label_text {
2585 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2586 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2587 let label_x_min = text_x - max_width / 2.0;
2588 let label_x_max = text_x + max_width / 2.0;
2589 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2590 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2591 } else {
2592 0.0
2593 };
2594 let rotation = if delay_offset > 0.0 {
2596 let dx = x2 - x1;
2597 let dy = delay_offset;
2598 let angle_rad = dy.atan2(dx.abs());
2599 let angle_deg = angle_rad.to_degrees();
2600 if dx < 0.0 { -angle_deg } else { angle_deg }
2602 } else {
2603 0.0
2604 };
2605
2606 for (i, line) in lines.iter().enumerate() {
2607 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2608 if rotation.abs() > 0.1 {
2609 writeln!(
2611 svg,
2612 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
2613 x = text_x,
2614 y = line_y,
2615 rot = rotation,
2616 cx = text_x,
2617 cy = line_y,
2618 t = escape_xml(line)
2619 )
2620 .unwrap();
2621 } else {
2622 writeln!(
2623 svg,
2624 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2625 x = text_x,
2626 y = line_y,
2627 t = escape_xml(line)
2628 )
2629 .unwrap();
2630 }
2631 }
2632
2633 writeln!(svg, r#"</g>"#).unwrap();
2635
2636 state.current_y += state.config.row_height + delay_offset;
2638 }
2639
2640 if create {
2641 state.current_y += CREATE_MESSAGE_SPACING;
2642 }
2643
2644 state.apply_serial_first_row_gap();
2645
2646 if activate {
2648 state
2649 .activations
2650 .entry(to.to_string())
2651 .or_default()
2652 .push((y, None));
2653 }
2654 if deactivate {
2655 if let Some(acts) = state.activations.get_mut(from) {
2656 if let Some(act) = acts.last_mut() {
2657 if act.1.is_none() {
2658 act.1 = Some(y);
2659 }
2660 }
2661 }
2662 }
2663}
2664
2665fn render_note(
2666 svg: &mut String,
2667 state: &mut RenderState,
2668 position: &NotePosition,
2669 participants: &[String],
2670 text: &str,
2671) {
2672 let lines: Vec<&str> = text.split("\\n").collect();
2673 let line_height = note_line_height(&state.config);
2674
2675 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
2677 let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
2678 let content_width = (NOTE_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
2679 let note_height = NOTE_PADDING * 2.0 + lines.len() as f64 * line_height;
2680
2681 let (x, note_width, text_anchor) = match position {
2682 NotePosition::Left => {
2683 let px = state.get_x(&participants[0]);
2684 let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
2686 (x, content_width, "start")
2687 }
2688 NotePosition::Right => {
2689 let px = state.get_x(&participants[0]);
2690 (px + NOTE_MARGIN, content_width, "start")
2692 }
2693 NotePosition::Over => {
2694 if participants.len() == 1 {
2695 let px = state.get_x(&participants[0]);
2696 let x = (px - content_width / 2.0).max(state.config.padding);
2698 (x, content_width, "middle")
2699 } else {
2700 let x1 = state.get_x(&participants[0]);
2702 let x2 = state.get_x(participants.last().unwrap());
2703 let span_width = (x2 - x1).abs() + NOTE_MARGIN * 2.0;
2704 let w = span_width.max(content_width);
2705 let x = (x1 - NOTE_MARGIN).max(state.config.padding);
2706 (x, w, "middle")
2707 }
2708 }
2709 };
2710
2711 let y = state.current_y;
2712 let fold_size = NOTE_FOLD_SIZE;
2713
2714 let note_path = format!(
2717 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2718 x = x,
2719 y = y,
2720 x2 = x + note_width - fold_size,
2721 x3 = x + note_width,
2722 y2 = y + fold_size,
2723 y3 = y + note_height
2724 );
2725
2726 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2727
2728 let theme = &state.config.theme;
2730 let fold_path = format!(
2732 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2733 x1 = x + note_width - fold_size,
2734 y1 = y,
2735 x2 = x + note_width,
2736 y2 = y + fold_size
2737 );
2738
2739 writeln!(
2740 svg,
2741 r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
2742 path = fold_path,
2743 stroke = theme.note_stroke
2744 )
2745 .unwrap();
2746
2747 let text_x = match text_anchor {
2749 "middle" => x + note_width / 2.0,
2750 _ => x + NOTE_PADDING,
2751 };
2752 let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
2753
2754 for (i, line) in lines.iter().enumerate() {
2755 let text_y = y + NOTE_PADDING + (i as f64 + 0.8) * line_height;
2756 writeln!(
2757 svg,
2758 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2759 x = text_x,
2760 y = text_y,
2761 anchor = text_anchor_attr,
2762 t = escape_xml(line)
2763 )
2764 .unwrap();
2765 }
2766
2767 state.current_y += note_height.max(state.config.row_height) + ROW_SPACING;
2769}
2770
2771fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2773 let theme = &state.config.theme;
2774 let lines: Vec<&str> = text.split("\\n").collect();
2775 let line_height = state_line_height(&state.config);
2776 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2777
2778 let (x, box_width) = if participants.len() == 1 {
2780 let px = state.get_x(&participants[0]);
2781 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2782 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2783 (px - w / 2.0, w)
2784 } else {
2785 let x1 = state.get_x(&participants[0]);
2786 let x2 = state.get_x(participants.last().unwrap());
2787 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2788 let center = (x1 + x2) / 2.0;
2789 (center - span_width / 2.0, span_width)
2790 };
2791
2792 let shift = item_pre_shift(&state.config);
2793 let y = (state.current_y - shift).max(state.content_start());
2794
2795 writeln!(
2797 svg,
2798 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2799 x = x,
2800 y = y,
2801 w = box_width,
2802 h = box_height,
2803 fill = theme.state_fill,
2804 stroke = theme.state_stroke
2805 )
2806 .unwrap();
2807
2808 let text_x = x + box_width / 2.0;
2810 for (i, line) in lines.iter().enumerate() {
2811 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2812 writeln!(
2813 svg,
2814 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2815 x = text_x,
2816 y = text_y,
2817 fill = theme.state_text_color,
2818 font = theme.font_family,
2819 size = state.config.font_size,
2820 t = escape_xml(line)
2821 )
2822 .unwrap();
2823 }
2824
2825 state.current_y = y + box_height + state.config.row_height + REF_EXTRA_GAP;
2826}
2827
2828fn render_ref(
2830 svg: &mut String,
2831 state: &mut RenderState,
2832 participants: &[String],
2833 text: &str,
2834 input_from: Option<&str>,
2835 input_label: Option<&str>,
2836 output_to: Option<&str>,
2837 output_label: Option<&str>,
2838) {
2839 let theme = &state.config.theme;
2840 let lines: Vec<&str> = text.split("\\n").collect();
2841 let line_height = ref_line_height(&state.config);
2842 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2843 let notch_size = 10.0;
2844
2845 let (x, box_width) = if participants.len() == 1 {
2847 let px = state.get_x(&participants[0]);
2848 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2849 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2850 .max(100.0);
2851 (px - w / 2.0, w)
2852 } else {
2853 let x1 = state.get_x(&participants[0]);
2854 let x2 = state.get_x(participants.last().unwrap());
2855 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2856 let center = (x1 + x2) / 2.0;
2857 (center - span_width / 2.0, span_width)
2858 };
2859
2860 let shift = item_pre_shift(&state.config);
2861 let y = (state.current_y - shift).max(state.content_start());
2862 let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2863 let output_padding = state.config.note_padding + 3.0;
2864
2865 if let Some(from) = input_from {
2867 let from_x = state.get_x(from);
2868 let to_x = x; let arrow_y = y + input_offset;
2870
2871 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2873 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2874 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2875
2876 writeln!(
2878 svg,
2879 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2880 x1 = from_x,
2881 y = arrow_y,
2882 x2 = line_end_x
2883 )
2884 .unwrap();
2885
2886 writeln!(
2888 svg,
2889 r#"<polygon points="{points}" class="arrowhead"/>"#,
2890 points = arrow_points
2891 )
2892 .unwrap();
2893
2894 if let Some(label) = input_label {
2896 let text_x = (from_x + to_x) / 2.0;
2897 writeln!(
2898 svg,
2899 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2900 x = text_x,
2901 y = arrow_y - 8.0,
2902 t = escape_xml(label)
2903 )
2904 .unwrap();
2905 }
2906 }
2907
2908 let ref_path = format!(
2911 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2912 x1 = x + notch_size,
2913 y1 = y,
2914 x2 = x + box_width,
2915 y2 = y + box_height,
2916 x3 = x,
2917 y3 = y + box_height / 2.0
2918 );
2919
2920 writeln!(
2921 svg,
2922 r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2923 path = ref_path,
2924 fill = theme.ref_fill,
2925 stroke = theme.ref_stroke
2926 )
2927 .unwrap();
2928
2929 writeln!(
2931 svg,
2932 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2933 x = x + notch_size + 4.0,
2934 y = y + state.config.font_size,
2935 fill = theme.ref_text_color,
2936 font = theme.font_family,
2937 size = state.config.font_size - 2.0
2938 )
2939 .unwrap();
2940
2941 let text_x = x + box_width / 2.0;
2943 for (i, line) in lines.iter().enumerate() {
2944 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2945 writeln!(
2946 svg,
2947 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2948 x = text_x,
2949 y = text_y,
2950 fill = theme.ref_text_color,
2951 font = theme.font_family,
2952 size = state.config.font_size,
2953 t = escape_xml(line)
2954 )
2955 .unwrap();
2956 }
2957
2958 if let Some(to) = output_to {
2960 let from_x = x + box_width; let to_x = state.get_x(to);
2962 let arrow_y = y + box_height - output_padding;
2963
2964 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2966 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2967 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2968
2969 writeln!(
2971 svg,
2972 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
2973 x1 = from_x,
2974 y = arrow_y,
2975 x2 = line_end_x
2976 )
2977 .unwrap();
2978
2979 writeln!(
2981 svg,
2982 r#"<polygon points="{points}" class="arrowhead"/>"#,
2983 points = arrow_points
2984 )
2985 .unwrap();
2986
2987 if let Some(label) = output_label {
2989 let text_x = (from_x + to_x) / 2.0;
2990 writeln!(
2991 svg,
2992 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2993 x = text_x,
2994 y = arrow_y - 8.0,
2995 t = escape_xml(label)
2996 )
2997 .unwrap();
2998 }
2999 }
3000
3001 state.current_y = y + box_height + state.config.row_height;
3002}
3003
3004fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3006 let theme = &state.config.theme;
3007 let lines: Vec<&str> = text.split("\\n").collect();
3008 let line_height = state.config.font_size + 4.0;
3009
3010 let x = state.config.padding + 10.0;
3012 let y = state.current_y;
3013
3014 for (i, line) in lines.iter().enumerate() {
3015 let text_y = y + (i as f64 + 0.8) * line_height;
3016 writeln!(
3017 svg,
3018 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3019 x = x,
3020 y = text_y,
3021 fill = theme.description_text_color,
3022 font = theme.font_family,
3023 size = state.config.font_size - 1.0,
3024 t = escape_xml(line)
3025 )
3026 .unwrap();
3027 }
3028
3029 state.current_y += lines.len() as f64 * line_height + 10.0;
3030}
3031
3032fn render_block(
3033 svg: &mut String,
3034 state: &mut RenderState,
3035 kind: &BlockKind,
3036 _label: &str,
3037 items: &[Item],
3038 else_items: Option<&[Item]>,
3039 depth: usize,
3040) {
3041 if block_is_parallel(kind) {
3042 state.push_parallel();
3043 let start_y = state.current_y;
3044 let mut max_end_y = start_y;
3045 for item in items {
3046 state.current_y = start_y;
3047 render_items(svg, state, std::slice::from_ref(item), depth);
3048 if state.current_y > max_end_y {
3049 max_end_y = state.current_y;
3050 }
3051 }
3052 let gap = if parallel_needs_gap(items) {
3053 BLOCK_GAP
3054 } else {
3055 0.0
3056 };
3057 state.current_y = max_end_y + gap;
3058 state.pop_parallel();
3059 return;
3060 }
3061
3062 if matches!(kind, BlockKind::Serial) {
3063 state.push_serial_first_row_pending();
3064 render_items(svg, state, items, depth);
3065 if let Some(else_items) = else_items {
3066 render_items(svg, state, else_items, depth);
3067 }
3068 state.pop_serial_first_row_pending();
3069 return;
3070 }
3071
3072 if !block_has_frame(kind) {
3073 render_items(svg, state, items, depth);
3074 if let Some(else_items) = else_items {
3075 render_items(svg, state, else_items, depth);
3076 }
3077 return;
3078 }
3079
3080 state.current_y += block_header_space(&state.config, depth);
3085
3086 render_items(svg, state, items, depth + 1);
3088
3089 if let Some(else_items) = else_items {
3091 state.push_else_return_pending();
3092 state.current_y += block_else_before(&state.config, depth);
3094 state.current_y += block_else_after(&state.config, depth);
3096 render_items(svg, state, else_items, depth + 1);
3097 state.pop_else_return_pending();
3098 }
3099
3100 let end_y = state.current_y + block_footer_padding(&state.config, depth);
3103
3104 state.current_y = end_y + state.config.row_height;
3106
3107 }
3110
3111fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3112 for (participant, activations) in &state.activations {
3113 let x = state.get_x(participant);
3114 let box_x = x - state.config.activation_width / 2.0;
3115
3116 for (start_y, end_y) in activations {
3117 let end = end_y.unwrap_or(footer_y);
3119 let height = end - start_y;
3120
3121 if height > 0.0 {
3122 writeln!(
3123 svg,
3124 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3125 x = box_x,
3126 y = start_y,
3127 w = state.config.activation_width,
3128 h = height
3129 )
3130 .unwrap();
3131 }
3132 }
3133 }
3134}
3135
3136fn escape_xml(s: &str) -> String {
3137 s.replace('&', "&")
3138 .replace('<', "<")
3139 .replace('>', ">")
3140 .replace('"', """)
3141 .replace('\'', "'")
3142}
3143
3144#[cfg(test)]
3145mod tests {
3146 use super::*;
3147 use crate::parser::parse;
3148
3149 #[test]
3150 fn test_render_simple() {
3151 let diagram = parse("Alice->Bob: Hello").unwrap();
3152 let svg = render(&diagram);
3153 assert!(svg.contains("<svg"));
3154 assert!(svg.contains("Alice"));
3155 assert!(svg.contains("Bob"));
3156 assert!(svg.contains("Hello"));
3157 }
3158
3159 #[test]
3160 fn test_render_with_note() {
3161 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3162 let svg = render(&diagram);
3163 assert!(svg.contains("Thinking"));
3164 }
3165}