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