1pub mod d2;
4pub mod excalidraw;
5pub mod plantuml;
6
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10use quick_xml::Reader;
11use quick_xml::events::Event;
12
13use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
14use crate::error::{Error, Result};
15use crate::ir::{
16 IDENTITY, LineCap, LineJoin, Node, Page, Paint, SourceMeta, Stroke, TextAnchor, TextRun,
17};
18
19#[derive(Clone, Debug)]
20pub struct DiagramNode {
21 pub id: String,
22 pub label: String,
23 pub shape: NodeShape,
24 pub x: f64,
25 pub y: f64,
26 pub width: f64,
27 pub height: f64,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum NodeShape {
32 Box,
33 Rounded,
34 Circle,
35 Diamond,
36 Cylinder,
37}
38
39#[derive(Clone, Debug)]
40pub struct DiagramEdge {
41 pub from: String,
42 pub to: String,
43 pub label: Option<String>,
44}
45
46#[derive(Clone, Debug, Default)]
47pub struct DiagramGraph {
48 pub title: Option<String>,
49 pub is_directed: bool,
50 pub nodes: Vec<DiagramNode>,
51 pub edges: Vec<DiagramEdge>,
52 pub raw_source: String,
53}
54
55pub(crate) fn convert(
56 path: &Path,
57 options: &ConvertOptions,
58 sink: &mut dyn PageConsumer,
59) -> Result<Vec<String>> {
60 let source_bytes = read_limited_file(path, options.max_input_bytes, "diagram input")?;
61 let source = String::from_utf8(source_bytes)
62 .map_err(|e| Error::InvalidInput(format!("diagram file is not valid UTF-8: {e}")))?;
63
64 let trimmed = source.trim_start();
65 if trimmed.starts_with("sequenceDiagram") {
66 let seq = parse_sequence_diagram(&source)?;
67 let page = layout_and_render_sequence(&seq, options)?;
68 sink.consume(page)?;
69 return Ok(Vec::new());
70 }
71
72 let is_mermaid = path
73 .extension()
74 .and_then(|ext| ext.to_str())
75 .is_some_and(|ext| ext.eq_ignore_ascii_case("mmd") || ext.eq_ignore_ascii_case("mermaid"))
76 || trimmed.starts_with("graph")
77 || trimmed.starts_with("flowchart")
78 || trimmed.starts_with("sequenceDiagram")
79 || trimmed.starts_with("classDiagram")
80 || trimmed.starts_with("stateDiagram")
81 || trimmed.starts_with("erDiagram")
82 || trimmed.starts_with("gantt")
83 || trimmed.starts_with("pie")
84 || trimmed.starts_with("mindmap")
85 || trimmed.starts_with("gitGraph");
86
87 let graph = if is_mermaid {
88 parse_mermaid(&source)?
89 } else {
90 parse_dot(&source)?
91 };
92
93 let page = layout_and_render_graph(&graph, options)?;
94 sink.consume(page)?;
95 Ok(Vec::new())
96}
97
98pub fn parse_dot(source: &str) -> Result<DiagramGraph> {
100 let mut graph = DiagramGraph {
101 is_directed: true,
102 raw_source: source.to_string(),
103 ..Default::default()
104 };
105
106 let mut node_labels: HashMap<String, (String, NodeShape)> = HashMap::new();
107 let mut edges: Vec<DiagramEdge> = Vec::new();
108 let mut node_set: HashSet<String> = HashSet::new();
109
110 let tokens = tokenize(source);
111 let mut i = 0;
112
113 while i < tokens.len() {
115 let tok = &tokens[i];
116 if tok == "digraph" || tok == "graph" {
117 graph.is_directed = tok == "digraph";
118 i += 1;
119 if i < tokens.len() && tokens[i] != "{" {
120 graph.title = Some(tokens[i].clone());
121 i += 1;
122 }
123 break;
124 }
125 i += 1;
126 }
127
128 while i < tokens.len() {
130 let tok = &tokens[i];
131 if tok == "}" {
132 break;
133 }
134
135 if i + 1 < tokens.len() && (tokens[i + 1] == "->" || tokens[i + 1] == "--") {
137 let from = clean_id(tok);
138 let mut j = i + 1;
139 while j < tokens.len() && (tokens[j] == "->" || tokens[j] == "--") {
140 j += 1;
141 if j >= tokens.len() {
142 break;
143 }
144 let to = clean_id(&tokens[j]);
145 node_set.insert(from.clone());
146 node_set.insert(to.clone());
147
148 let mut edge_label = None;
149 if j + 1 < tokens.len() && tokens[j + 1] == "[" {
150 let (attrs, next_idx) = parse_attributes(&tokens, j + 1);
151 edge_label = attrs.get("label").cloned();
152 j = next_idx;
153 }
154 edges.push(DiagramEdge {
155 from: from.clone(),
156 to: to.clone(),
157 label: edge_label,
158 });
159 j += 1;
160 }
161 i = j;
162 continue;
163 } else if i + 1 < tokens.len() && tokens[i + 1] == "[" {
164 let id = clean_id(tok);
165 let (attrs, next_idx) = parse_attributes(&tokens, i + 1);
166 let label = attrs.get("label").cloned().unwrap_or_else(|| id.clone());
167 let shape = match attrs.get("shape").map(|s| s.as_str()) {
168 Some("circle") | Some("ellipse") => NodeShape::Circle,
169 Some("diamond") => NodeShape::Diamond,
170 Some("cylinder") => NodeShape::Cylinder,
171 Some("rounded") => NodeShape::Rounded,
172 _ => NodeShape::Box,
173 };
174 node_labels.insert(id.clone(), (label, shape));
175 node_set.insert(id);
176 i = next_idx + 1;
177 continue;
178 } else if !tok.is_empty()
179 && tok != ";"
180 && tok != "{"
181 && tok != "}"
182 && !tok.starts_with("subgraph")
183 {
184 let id = clean_id(tok);
185 if !id.is_empty() {
186 node_set.insert(id);
187 }
188 }
189 i += 1;
190 }
191
192 for id in node_set {
193 let (label, shape) = node_labels
194 .remove(&id)
195 .unwrap_or_else(|| (id.clone(), NodeShape::Rounded));
196 graph.nodes.push(DiagramNode {
197 id,
198 label,
199 shape,
200 x: 0.0,
201 y: 0.0,
202 width: 120.0,
203 height: 44.0,
204 });
205 }
206 graph.edges = edges;
207 graph.nodes.sort_by(|a, b| a.id.cmp(&b.id));
208
209 Ok(graph)
210}
211
212pub fn parse_mermaid(source: &str) -> Result<DiagramGraph> {
214 let mut graph = DiagramGraph {
215 is_directed: true,
216 raw_source: source.to_string(),
217 ..Default::default()
218 };
219
220 let mut node_map: HashMap<String, (String, NodeShape)> = HashMap::new();
221 let mut edges: Vec<DiagramEdge> = Vec::new();
222
223 for line in source.lines() {
224 let line = line.trim();
225 if line.is_empty()
226 || line.starts_with("%%")
227 || line.starts_with("graph")
228 || line.starts_with("flowchart")
229 || line.starts_with("stateDiagram")
230 || line.starts_with("classDiagram")
231 || line.starts_with("erDiagram")
232 || line.starts_with("gitGraph")
233 || line.starts_with("subgraph")
234 || line == "end"
235 || line.starts_with("direction ")
236 || line.starts_with("note ")
237 {
238 continue;
239 }
240
241 let mut current_segment = line;
242 let mut had_arrow = false;
243
244 while let Some((arrow_pos, arrow_len, is_bidirectional)) =
245 find_mermaid_arrow(current_segment)
246 {
247 had_arrow = true;
248 let left_part = current_segment[..arrow_pos].trim();
249 let remainder = current_segment[arrow_pos + arrow_len..].trim();
250
251 let (actual_left, inline_label) = if let Some(dash_idx) = left_part.find("--") {
253 let node_str = left_part[..dash_idx].trim();
254 let lbl = left_part[dash_idx + 2..]
255 .trim()
256 .trim_matches('"')
257 .to_string();
258 (node_str, if lbl.is_empty() { None } else { Some(lbl) })
259 } else {
260 (left_part, None)
261 };
262
263 let (from_id, from_label, from_shape) = parse_mermaid_node_token(actual_left);
264
265 let (post_label_part, pipe_label) = if let Some(stripped) = remainder.strip_prefix('|')
267 {
268 if let Some(end_bar) = stripped.find('|') {
269 (
270 stripped[end_bar + 1..].trim(),
271 Some(stripped[..end_bar].trim().to_string()),
272 )
273 } else {
274 (remainder, None)
275 }
276 } else {
277 (remainder, None)
278 };
279
280 let (actual_right, right_label, next_segment) =
283 if let Some((next_pos, _, _)) = find_mermaid_arrow(post_label_part) {
284 let target_node = post_label_part[..next_pos].trim();
285 (target_node, None, post_label_part)
286 } else if let Some(colon_pos) = post_label_part.find(':') {
287 let node_str = post_label_part[..colon_pos].trim();
288 let lbl = post_label_part[colon_pos + 1..]
289 .trim()
290 .trim_matches('"')
291 .to_string();
292 (node_str, if lbl.is_empty() { None } else { Some(lbl) }, "")
293 } else {
294 (post_label_part, None, "")
295 };
296
297 let label = pipe_label.or(inline_label).or(right_label);
298 let (to_id, to_label, to_shape) = parse_mermaid_node_token(actual_right);
299
300 if !from_id.is_empty() {
301 node_map
302 .entry(from_id.clone())
303 .or_insert((from_label, from_shape));
304 }
305 if !to_id.is_empty() {
306 node_map
307 .entry(to_id.clone())
308 .or_insert((to_label, to_shape));
309 }
310
311 if !from_id.is_empty() && !to_id.is_empty() {
312 edges.push(DiagramEdge {
313 from: from_id.clone(),
314 to: to_id.clone(),
315 label,
316 });
317 if is_bidirectional {
318 edges.push(DiagramEdge {
319 from: to_id,
320 to: from_id,
321 label: None,
322 });
323 }
324 }
325
326 if next_segment.is_empty() {
327 break;
328 }
329 current_segment = next_segment;
330 }
331
332 if !had_arrow {
333 let (id, label, shape) = parse_mermaid_node_token(line);
334 if !id.is_empty() {
335 node_map.entry(id).or_insert((label, shape));
336 }
337 }
338 }
339
340 for (id, (label, shape)) in node_map {
341 graph.nodes.push(DiagramNode {
342 id,
343 label,
344 shape,
345 x: 0.0,
346 y: 0.0,
347 width: 120.0,
348 height: 44.0,
349 });
350 }
351 graph.nodes.sort_by(|a, b| a.id.cmp(&b.id));
352 graph.edges = edges;
353
354 Ok(graph)
355}
356
357fn find_mermaid_arrow(s: &str) -> Option<(usize, usize, bool)> {
358 if let Some(idx) = s.find("<-->") {
359 Some((idx, 4, true))
360 } else if let Some(idx) = s.find("-.->") {
361 Some((idx, 4, false))
362 } else if let Some(idx) = s.find("<|--") {
363 Some((idx, 4, true))
364 } else if let Some(idx) = s.find("--|>") {
365 Some((idx, 4, false))
366 } else if let Some(idx) = s.find("*--") {
367 Some((idx, 3, false))
368 } else if let Some(idx) = s.find("--*") {
369 Some((idx, 3, false))
370 } else if let Some(idx) = s.find("o--") {
371 Some((idx, 3, false))
372 } else if let Some(idx) = s.find("--o") {
373 Some((idx, 3, false))
374 } else if let Some(idx) = s.find("==>") {
375 Some((idx, 3, false))
376 } else if let Some(idx) = s.find("-->") {
377 Some((idx, 3, false))
378 } else {
379 s.find("---").map(|idx| (idx, 3, false))
380 }
381}
382
383fn parse_mermaid_node_token(token: &str) -> (String, String, NodeShape) {
384 let t = token.trim();
385 if t == "[*]" {
386 return ("[*]".to_string(), "●".to_string(), NodeShape::Circle);
387 }
388 if let (Some(start), Some(end)) = (token.find("[["), token.rfind("]]"))
389 && start + 2 <= end
390 {
391 let id = token[..start].trim().to_string();
392 let label = token[start + 2..end].trim().trim_matches('"').to_string();
393 return (id, label, NodeShape::Box);
394 }
395 if let (Some(start), Some(end)) = (token.find("[("), token.rfind(")]"))
396 && start + 2 <= end
397 {
398 let id = token[..start].trim().to_string();
399 let label = token[start + 2..end].trim().trim_matches('"').to_string();
400 return (id, label, NodeShape::Cylinder);
401 }
402 if let (Some(start), Some(end)) = (token.find("(["), token.rfind("])"))
403 && start + 2 <= end
404 {
405 let id = token[..start].trim().to_string();
406 let label = token[start + 2..end].trim().trim_matches('"').to_string();
407 return (id, label, NodeShape::Rounded);
408 }
409 if let (Some(start), Some(end)) = (token.find("((("), token.rfind(")))"))
410 && start + 3 <= end
411 {
412 let id = token[..start].trim().to_string();
413 let label = token[start + 3..end].trim().trim_matches('"').to_string();
414 return (id, label, NodeShape::Circle);
415 }
416 if let (Some(start), Some(end)) = (token.find("(("), token.rfind("))"))
417 && start + 2 <= end
418 {
419 let id = token[..start].trim().to_string();
420 let label = token[start + 2..end].trim().trim_matches('"').to_string();
421 return (id, label, NodeShape::Circle);
422 }
423 if let (Some(start), Some(end)) = (token.find("{{"), token.rfind("}}"))
424 && start + 2 <= end
425 {
426 let id = token[..start].trim().to_string();
427 let label = token[start + 2..end].trim().trim_matches('"').to_string();
428 return (id, label, NodeShape::Diamond);
429 }
430 if let (Some(start), Some(end)) = (token.find('>'), token.rfind(']'))
431 && start < end
432 {
433 let id = token[..start].trim().to_string();
434 let label = token[start + 1..end].trim().trim_matches('"').to_string();
435 return (id, label, NodeShape::Box);
436 }
437 if let (Some(start), Some(end)) = (token.find('['), token.rfind(']'))
438 && start < end
439 {
440 let id = token[..start].trim().to_string();
441 let label = token[start + 1..end].trim().trim_matches('"').to_string();
442 return (id, label, NodeShape::Box);
443 }
444 if let (Some(start), Some(end)) = (token.find('{'), token.rfind('}'))
445 && start < end
446 {
447 let id = token[..start].trim().to_string();
448 let label = token[start + 1..end].trim().trim_matches('"').to_string();
449 return (id, label, NodeShape::Diamond);
450 }
451 if let (Some(start), Some(end)) = (token.find('('), token.rfind(')'))
452 && start < end
453 {
454 let id = token[..start].trim().to_string();
455 let label = token[start + 1..end].trim().trim_matches('"').to_string();
456 return (id, label, NodeShape::Rounded);
457 }
458 let id = clean_id(token);
459 (id.clone(), id, NodeShape::Rounded)
460}
461
462fn clean_id(raw: &str) -> String {
463 raw.trim().trim_matches('"').trim_matches(';').to_string()
464}
465
466fn tokenize(source: &str) -> Vec<String> {
467 let mut tokens = Vec::new();
468 let mut chars = source.chars().peekable();
469
470 while let Some(&c) = chars.peek() {
471 if c.is_whitespace() {
472 chars.next();
473 } else if c == '/' && chars.clone().nth(1) == Some('/') {
474 for ch in chars.by_ref() {
475 if ch == '\n' {
476 break;
477 }
478 }
479 } else if c == '"' {
480 chars.next();
481 let mut s = String::new();
482 for ch in chars.by_ref() {
483 if ch == '"' {
484 break;
485 }
486 s.push(ch);
487 }
488 tokens.push(format!("\"{s}\""));
489 } else if c == '-' && chars.clone().nth(1) == Some('>') {
490 chars.next();
491 chars.next();
492 tokens.push("->".to_string());
493 } else if c == '-' && chars.clone().nth(1) == Some('-') {
494 chars.next();
495 chars.next();
496 tokens.push("--".to_string());
497 } else if "{}[];=,".contains(c) {
498 tokens.push(c.to_string());
499 chars.next();
500 } else {
501 let mut s = String::new();
502 while let Some(&ch) = chars.peek() {
503 if ch.is_whitespace() || "{}[];=,\"-/".contains(ch) {
504 break;
505 }
506 s.push(ch);
507 chars.next();
508 }
509 if !s.is_empty() {
510 tokens.push(s);
511 }
512 }
513 }
514 tokens
515}
516
517fn parse_attributes(tokens: &[String], start: usize) -> (HashMap<String, String>, usize) {
518 let mut attrs = HashMap::new();
519 let mut idx = start;
520 if tokens.get(idx).map(|s| s.as_str()) != Some("[") {
521 return (attrs, start);
522 }
523 idx += 1;
524 while idx < tokens.len() {
525 if tokens[idx] == "]" {
526 return (attrs, idx);
527 }
528 let key = clean_id(&tokens[idx]);
529 if idx + 2 < tokens.len() && tokens[idx + 1] == "=" {
530 let val = clean_id(&tokens[idx + 2]);
531 attrs.insert(key, val);
532 idx += 3;
533 if idx < tokens.len() && (tokens[idx] == "," || tokens[idx] == ";") {
534 idx += 1;
535 }
536 } else {
537 idx += 1;
538 }
539 }
540 (attrs, idx)
541}
542
543pub fn layout_and_render_graph(graph: &DiagramGraph, _options: &ConvertOptions) -> Result<Page> {
545 let node_indices: HashMap<String, usize> = graph
546 .nodes
547 .iter()
548 .enumerate()
549 .map(|(i, n)| (n.id.clone(), i))
550 .collect();
551
552 let mut in_degree: HashMap<String, usize> = HashMap::new();
553 let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
554 for n in &graph.nodes {
555 in_degree.insert(n.id.clone(), 0);
556 adjacency.insert(n.id.clone(), Vec::new());
557 }
558 for e in &graph.edges {
559 if node_indices.contains_key(&e.from) && node_indices.contains_key(&e.to) {
560 *in_degree.entry(e.to.clone()).or_insert(0) += 1;
561 adjacency
562 .entry(e.from.clone())
563 .or_default()
564 .push(e.to.clone());
565 }
566 }
567
568 let mut layers: Vec<Vec<String>> = Vec::new();
569 let mut current_layer: Vec<String> = graph
570 .nodes
571 .iter()
572 .filter(|n| in_degree.get(&n.id).copied().unwrap_or(0) == 0)
573 .map(|n| n.id.clone())
574 .collect();
575
576 if current_layer.is_empty() && !graph.nodes.is_empty() {
577 current_layer.push(graph.nodes[0].id.clone());
578 }
579
580 let mut placed: HashSet<String> = current_layer.iter().cloned().collect();
581 layers.push(current_layer);
582
583 while placed.len() < graph.nodes.len() {
584 let mut next_layer = Vec::new();
585 if let Some(prev) = layers.last() {
586 for node_id in prev {
587 if let Some(neighbors) = adjacency.get(node_id) {
588 for neighbor in neighbors {
589 if !placed.contains(neighbor) {
590 placed.insert(neighbor.clone());
591 next_layer.push(neighbor.clone());
592 }
593 }
594 }
595 }
596 }
597 if next_layer.is_empty() {
598 for n in &graph.nodes {
599 if !placed.contains(&n.id) {
600 placed.insert(n.id.clone());
601 next_layer.push(n.id.clone());
602 break;
603 }
604 }
605 }
606 layers.push(next_layer);
607 }
608
609 let node_width = 130.0;
610 let node_height = 46.0;
611 let horizontal_gap = 40.0;
612 let vertical_gap = 60.0;
613 let padding = 40.0;
614
615 let mut node_positions: HashMap<String, (f64, f64)> = HashMap::new();
616 let mut max_layer_width = 0.0f64;
617
618 for layer in &layers {
619 let count = layer.len() as f64;
620 let layer_width = count * node_width + (count - 1.0).max(0.0) * horizontal_gap;
621 if layer_width > max_layer_width {
622 max_layer_width = layer_width;
623 }
624 }
625
626 let total_height = layers.len() as f64 * node_height
627 + (layers.len() as f64 - 1.0).max(0.0) * vertical_gap
628 + padding * 2.0;
629 let total_width = (max_layer_width + padding * 2.0).max(300.0);
630
631 let mut cur_y = padding;
632 for layer in &layers {
633 let count = layer.len() as f64;
634 let layer_width = count * node_width + (count - 1.0).max(0.0) * horizontal_gap;
635 let start_x = (total_width - layer_width) / 2.0;
636
637 for (col_idx, node_id) in layer.iter().enumerate() {
638 let x = start_x + col_idx as f64 * (node_width + horizontal_gap);
639 node_positions.insert(node_id.clone(), (x, cur_y));
640 }
641 cur_y += node_height + vertical_gap;
642 }
643
644 let mut page = Page::new(1, total_width, total_height, "diagram");
645 page.embedded_source = Some(graph.raw_source.clone());
646
647 for edge in &graph.edges {
649 if let (Some(&(x1, y1)), Some(&(x2, y2))) =
650 (node_positions.get(&edge.from), node_positions.get(&edge.to))
651 {
652 let start_point = (x1 + node_width / 2.0, y1 + node_height);
653 let end_point = (x2 + node_width / 2.0, y2);
654
655 let stroke = Stroke {
656 paint: Paint::solid("#4b5563"),
657 width: 1.5,
658 line_cap: LineCap::Round,
659 line_join: LineJoin::Round,
660 ..Default::default()
661 };
662
663 let mid_y = (start_point.1 + end_point.1) / 2.0;
664 let d = format!(
665 "M {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
666 start_point.0,
667 start_point.1,
668 start_point.0,
669 mid_y,
670 end_point.0,
671 mid_y,
672 end_point.0,
673 end_point.1 - 4.0
674 );
675
676 page.nodes.push(Node::Path {
677 id: String::new(),
678 d,
679 fill_rule: String::new(),
680 fill: Paint::None,
681 stroke,
682 transform: IDENTITY,
683 clip_id: None,
684 meta: SourceMeta::default(),
685 });
686
687 if graph.is_directed {
689 let arrow_tip = (end_point.0, end_point.1);
690 let arrow_d = format!(
691 "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
692 arrow_tip.0,
693 arrow_tip.1,
694 arrow_tip.0 - 4.0,
695 arrow_tip.1 - 7.0,
696 arrow_tip.0 + 4.0,
697 arrow_tip.1 - 7.0
698 );
699 page.nodes.push(Node::Path {
700 id: String::new(),
701 d: arrow_d,
702 fill_rule: String::new(),
703 fill: Paint::solid("#4b5563"),
704 stroke: Stroke::default(),
705 transform: IDENTITY,
706 clip_id: None,
707 meta: SourceMeta::default(),
708 });
709 }
710
711 if let Some(ref label) = edge.label {
713 let mid_x = (start_point.0 + end_point.0) / 2.0;
714 let mid_label_y = mid_y - 4.0;
715 page.nodes.push(Node::Text {
716 id: String::new(),
717 x: mid_x,
718 y: mid_label_y,
719 runs: vec![TextRun {
720 text: label.clone(),
721 font_size: 11.0,
722 font_family: "Helvetica, Arial, sans-serif".to_string(),
723 fill: Paint::solid("#374151"),
724 ..Default::default()
725 }],
726 anchor: TextAnchor::Middle,
727 transform: IDENTITY,
728 opacity: 1.0,
729 stroke: Stroke::default(),
730 clip_id: None,
731 meta: SourceMeta::default(),
732 });
733 }
734 }
735 }
736
737 for node in &graph.nodes {
739 if let Some(&(x, y)) = node_positions.get(&node.id) {
740 let fill_paint = Paint::solid("#f3f4f6");
741 let border_paint = Stroke {
742 paint: Paint::solid("#2563eb"),
743 width: 1.5,
744 line_cap: LineCap::Round,
745 line_join: LineJoin::Round,
746 ..Default::default()
747 };
748
749 let d = match node.shape {
750 NodeShape::Circle => {
751 let cx = x + node_width / 2.0;
752 let cy = y + node_height / 2.0;
753 let r = (node_height / 2.0).min(node_width / 2.0);
754 let c = 0.5522847498 * r;
755 format!(
756 "M {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} Z",
757 cx,
758 cy - r,
759 cx + c,
760 cy - r,
761 cx + r,
762 cy - c,
763 cx + r,
764 cy,
765 cx + r,
766 cy + c,
767 cx + c,
768 cy + r,
769 cx,
770 cy + r,
771 cx - c,
772 cy + r,
773 cx - r,
774 cy + c,
775 cx - r,
776 cy,
777 cx - r,
778 cy - c,
779 cx - c,
780 cy - r,
781 cx,
782 cy - r
783 )
784 }
785 NodeShape::Rounded => {
786 let r = 8.0f64;
787 format!(
788 "M {:.2},{:.2} L {:.2},{:.2} Q {:.2},{:.2} {:.2},{:.2} L {:.2},{:.2} Q {:.2},{:.2} {:.2},{:.2} L {:.2},{:.2} Q {:.2},{:.2} {:.2},{:.2} L {:.2},{:.2} Q {:.2},{:.2} {:.2},{:.2} Z",
789 x + r,
790 y,
791 x + node_width - r,
792 y,
793 x + node_width,
794 y,
795 x + node_width,
796 y + r,
797 x + node_width,
798 y + node_height - r,
799 x + node_width,
800 y + node_height,
801 x + node_width - r,
802 y + node_height,
803 x + r,
804 y + node_height,
805 x,
806 y + node_height,
807 x,
808 y + node_height - r,
809 x,
810 y + r,
811 x,
812 y,
813 x + r,
814 y
815 )
816 }
817 NodeShape::Diamond => {
818 let mx = x + node_width / 2.0;
819 let my = y + node_height / 2.0;
820 format!(
821 "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
822 mx,
823 y,
824 x + node_width,
825 my,
826 mx,
827 y + node_height,
828 x,
829 my
830 )
831 }
832 NodeShape::Cylinder => {
833 let _rx = node_width / 2.0;
834 let ry = 6.0;
835 let top_cy = y + ry;
836 let bot_cy = y + node_height - ry;
837 format!(
838 "M {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} L {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} Z",
839 x,
840 top_cy,
841 x,
842 top_cy - ry * 1.33,
843 x + node_width,
844 top_cy - ry * 1.33,
845 x + node_width,
846 top_cy,
847 x + node_width,
848 top_cy + ry * 1.33,
849 x,
850 top_cy + ry * 1.33,
851 x,
852 top_cy,
853 x,
854 bot_cy,
855 x,
856 bot_cy + ry * 1.33,
857 x + node_width,
858 bot_cy + ry * 1.33,
859 x + node_width,
860 bot_cy,
861 x + node_width,
862 bot_cy - ry * 1.33,
863 x,
864 bot_cy - ry * 1.33,
865 x,
866 bot_cy
867 )
868 }
869 NodeShape::Box => {
870 format!(
871 "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
872 x,
873 y,
874 x + node_width,
875 y,
876 x + node_width,
877 y + node_height,
878 x,
879 y + node_height
880 )
881 }
882 };
883
884 page.nodes.push(Node::Path {
885 id: String::new(),
886 d,
887 fill_rule: String::new(),
888 fill: fill_paint,
889 stroke: border_paint,
890 transform: IDENTITY,
891 clip_id: None,
892 meta: SourceMeta::default(),
893 });
894
895 let font_size = 13.0;
897 let text_x = x + node_width / 2.0;
898 let text_y = y + node_height / 2.0 + 4.5;
899 page.nodes.push(Node::Text {
900 id: String::new(),
901 x: text_x,
902 y: text_y,
903 runs: vec![TextRun {
904 text: node.label.clone(),
905 font_size,
906 font_family: "Helvetica, Arial, sans-serif".to_string(),
907 fill: Paint::solid("#1e293b"),
908 ..Default::default()
909 }],
910 anchor: TextAnchor::Middle,
911 transform: IDENTITY,
912 opacity: 1.0,
913 stroke: Stroke::default(),
914 clip_id: None,
915 meta: SourceMeta::default(),
916 });
917 }
918 }
919
920 Ok(page)
921}
922
923pub fn extract_diagram_from_svg(svg_bytes: &[u8]) -> Result<String> {
925 let svg_text = std::str::from_utf8(svg_bytes)
926 .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
927
928 if let Some(decoded) = crate::cad::svg_reader::extract_embedded_source(svg_bytes)
930 && !decoded.trim().is_empty()
931 {
932 return Ok(decoded);
933 }
934
935 let mut extracted_texts = Vec::new();
937 let mut reader = Reader::from_str(svg_text);
938 reader.config_mut().trim_text(true);
939
940 let mut in_text = false;
941 let mut current_text = String::new();
942
943 while let Ok(event) = reader.read_event() {
944 match event {
945 Event::Start(e) if e.name().as_ref() == b"text" => {
946 in_text = true;
947 current_text.clear();
948 }
949 Event::Text(e) if in_text => {
950 let bytes = e.as_ref();
951 if let Ok(s) = std::str::from_utf8(bytes) {
952 current_text.push_str(s);
953 }
954 }
955 Event::End(e) if e.name().as_ref() == b"text" => {
956 in_text = false;
957 let trimmed = current_text.trim();
958 if !trimmed.is_empty() {
959 extracted_texts.push(trimmed.to_string());
960 }
961 }
962 Event::Eof => break,
963 _ => {}
964 }
965 }
966
967 if extracted_texts.is_empty() {
968 return Ok("digraph G {\n}\n".to_string());
969 }
970
971 let mut dot = String::from("digraph G {\n node [shape=box, style=rounded];\n");
972 for (i, t) in extracted_texts.iter().enumerate() {
973 dot.push_str(&format!(" n{i} [label=\"{t}\"];\n"));
974 }
975 for i in 0..extracted_texts.len().saturating_sub(1) {
976 dot.push_str(&format!(" n{i} -> n{};\n", i + 1));
977 }
978 dot.push_str("}\n");
979
980 Ok(dot)
981}
982
983#[derive(Clone, Debug)]
984pub struct SequenceMessage {
985 pub from: String,
986 pub to: String,
987 pub text: String,
988 pub is_dotted: bool,
989}
990
991#[derive(Clone, Debug, PartialEq)]
992pub struct SequenceParticipant {
993 pub id: String,
994 pub label: String,
995 pub is_actor: bool,
996}
997
998#[derive(Clone, Debug, Default)]
999pub struct SequenceDiagram {
1000 pub participants: Vec<String>,
1001 pub participant_info: Vec<SequenceParticipant>,
1002 pub messages: Vec<SequenceMessage>,
1003 pub has_autonumber: bool,
1004 pub raw_source: String,
1005}
1006
1007pub fn parse_sequence_diagram(source: &str) -> Result<SequenceDiagram> {
1008 let mut participants = Vec::new();
1009 let mut participant_info = Vec::new();
1010 let mut alias_map: HashMap<String, String> = HashMap::new();
1011 let mut messages = Vec::new();
1012 let mut has_autonumber = false;
1013
1014 let add_participant = |id: &str,
1015 label: &str,
1016 is_actor: bool,
1017 parts: &mut Vec<String>,
1018 info: &mut Vec<SequenceParticipant>| {
1019 let id_clean = id.trim().trim_matches('"').to_string();
1020 let label_clean = label.trim().trim_matches('"').to_string();
1021 if id_clean.is_empty() {
1022 return;
1023 }
1024 let display = if label_clean.is_empty() {
1025 id_clean.clone()
1026 } else {
1027 label_clean
1028 };
1029 if !parts.contains(&display) {
1030 parts.push(display.clone());
1031 info.push(SequenceParticipant {
1032 id: id_clean,
1033 label: display,
1034 is_actor,
1035 });
1036 }
1037 };
1038
1039 for line in source.lines() {
1040 let line = line.trim();
1041 if line.is_empty() || line.starts_with("%%") || line.starts_with("sequenceDiagram") {
1042 continue;
1043 }
1044
1045 if line == "autonumber" || line.starts_with("autonumber ") {
1046 has_autonumber = true;
1047 continue;
1048 }
1049
1050 if let Some(rest) = line
1051 .strip_prefix("actor ")
1052 .or_else(|| line.strip_prefix("participant "))
1053 {
1054 let is_actor = line.starts_with("actor ");
1055 let rest = rest.trim();
1056 let (id, label) = if let Some(pos) = rest.find(" as ") {
1057 (rest[..pos].trim(), rest[pos + 4..].trim())
1058 } else {
1059 (rest, rest)
1060 };
1061 let id_clean = id.trim().trim_matches('"').to_string();
1062 let label_clean = label.trim().trim_matches('"').to_string();
1063 alias_map.insert(id_clean.clone(), label_clean.clone());
1064 add_participant(
1065 &id_clean,
1066 &label_clean,
1067 is_actor,
1068 &mut participants,
1069 &mut participant_info,
1070 );
1071 continue;
1072 }
1073
1074 let arrow_info = if let Some(pos) = line.find("-->>") {
1076 Some(("-->>", true, pos))
1077 } else if let Some(pos) = line.find("->>") {
1078 Some(("->>", false, pos))
1079 } else if let Some(pos) = line.find("-->") {
1080 Some(("-->", true, pos))
1081 } else {
1082 line.find("->").map(|pos| ("->", false, pos))
1083 };
1084
1085 if let Some((arrow, is_dotted, arrow_pos)) = arrow_info {
1086 let from_part = line[..arrow_pos].trim();
1087 let rest = &line[arrow_pos + arrow.len()..];
1088
1089 let (to_part, text_part) = if let Some(colon_pos) = rest.find(':') {
1090 (rest[..colon_pos].trim(), rest[colon_pos + 1..].trim())
1091 } else {
1092 (rest.trim(), "")
1093 };
1094
1095 let from_resolved = alias_map
1096 .get(from_part)
1097 .cloned()
1098 .unwrap_or_else(|| from_part.to_string());
1099 let to_resolved = alias_map
1100 .get(to_part)
1101 .cloned()
1102 .unwrap_or_else(|| to_part.to_string());
1103
1104 add_participant(
1105 from_part,
1106 &from_resolved,
1107 false,
1108 &mut participants,
1109 &mut participant_info,
1110 );
1111 add_participant(
1112 to_part,
1113 &to_resolved,
1114 false,
1115 &mut participants,
1116 &mut participant_info,
1117 );
1118
1119 messages.push(SequenceMessage {
1120 from: from_resolved,
1121 to: to_resolved,
1122 text: text_part.to_string(),
1123 is_dotted,
1124 });
1125 }
1126 }
1127
1128 Ok(SequenceDiagram {
1129 participants,
1130 participant_info,
1131 messages,
1132 has_autonumber,
1133 raw_source: source.to_string(),
1134 })
1135}
1136
1137pub fn layout_and_render_sequence(
1138 seq: &SequenceDiagram,
1139 _options: &ConvertOptions,
1140) -> Result<Page> {
1141 let participants: Vec<SequenceParticipant> = if !seq.participant_info.is_empty() {
1142 seq.participant_info.clone()
1143 } else {
1144 seq.participants
1145 .iter()
1146 .map(|p| SequenceParticipant {
1147 id: p.clone(),
1148 label: p.clone(),
1149 is_actor: false,
1150 })
1151 .collect()
1152 };
1153
1154 let actor_gap = 56.0;
1155 let margin_x = 48.0;
1156 let margin_top = 28.0;
1157 let margin_bottom = 28.0;
1158 let header_height = 64.0;
1159 let footer_height = 64.0;
1160 let message_gap = 48.0;
1161
1162 let widths: Vec<f64> = participants
1164 .iter()
1165 .map(|p| {
1166 let char_len = p.label.chars().count();
1167 (char_len as f64 * 7.5 + 32.0).max(104.0)
1168 })
1169 .collect();
1170
1171 let mut participant_centers: HashMap<String, f64> = HashMap::new();
1172 let mut cur_x = margin_x;
1173 for (p, &w) in participants.iter().zip(widths.iter()) {
1174 let center_x = cur_x + w / 2.0;
1175 participant_centers.insert(p.label.clone(), center_x);
1176 participant_centers.insert(p.id.clone(), center_x);
1177 cur_x += w + actor_gap;
1178 }
1179
1180 let total_width = (cur_x - actor_gap + margin_x).max(360.0);
1181 let messages_height = (seq.messages.len().max(1) as f64 + 1.0) * message_gap;
1182 let total_height = margin_top + header_height + messages_height + footer_height + margin_bottom;
1183
1184 let mut page = Page::new(1, total_width, total_height, "sequence_diagram");
1185 page.embedded_source = Some(seq.raw_source.clone());
1186
1187 let lifeline_top = margin_top + header_height;
1188 let lifeline_bottom = total_height - margin_bottom - footer_height;
1189
1190 for (p, &w) in participants.iter().zip(widths.iter()) {
1192 let center_x = *participant_centers.get(&p.label).unwrap();
1193
1194 let lifeline_d = format!(
1196 "M {:.1},{:.1} L {:.1},{:.1}",
1197 center_x, lifeline_top, center_x, lifeline_bottom
1198 );
1199 page.nodes.push(Node::Path {
1200 id: String::new(),
1201 d: lifeline_d,
1202 fill_rule: String::new(),
1203 fill: Paint::None,
1204 stroke: Stroke {
1205 paint: Paint::solid("#94a3b8"),
1206 width: 1.5,
1207 dash_array: vec![4.0, 4.0],
1208 ..Default::default()
1209 },
1210 transform: IDENTITY,
1211 clip_id: None,
1212 meta: SourceMeta::default(),
1213 });
1214
1215 let draw_participant = |is_top: bool, page_nodes: &mut Vec<Node>| {
1217 let base_y = if is_top { margin_top } else { lifeline_bottom };
1218
1219 if p.is_actor {
1220 let head_cy = base_y + 11.0;
1222 let head_d = format!(
1223 "M {:.1},{:.1} a 6.5 6.5 0 1 0 13 0 a 6.5 6.5 0 1 0 -13 0 Z",
1224 center_x - 6.5,
1225 head_cy
1226 );
1227 page_nodes.push(Node::Path {
1228 id: String::new(),
1229 d: head_d,
1230 fill_rule: String::new(),
1231 fill: Paint::solid("#f1f5f9"),
1232 stroke: Stroke {
1233 paint: Paint::solid("#2563eb"),
1234 width: 1.6,
1235 ..Default::default()
1236 },
1237 transform: IDENTITY,
1238 clip_id: None,
1239 meta: SourceMeta::default(),
1240 });
1241
1242 let neck_y = base_y + 17.5;
1244 let waist_y = base_y + 29.0;
1245 let arms_y = base_y + 21.0;
1246 let feet_y = base_y + 42.0;
1247
1248 let body_d = format!(
1249 "M {:.1},{:.1} L {:.1},{:.1} M {:.1},{:.1} L {:.1},{:.1} M {:.1},{:.1} L {:.1},{:.1} M {:.1},{:.1} L {:.1},{:.1}",
1250 center_x,
1251 neck_y,
1252 center_x,
1253 waist_y,
1254 center_x - 12.0,
1255 arms_y,
1256 center_x + 12.0,
1257 arms_y,
1258 center_x,
1259 waist_y,
1260 center_x - 9.0,
1261 feet_y,
1262 center_x,
1263 waist_y,
1264 center_x + 9.0,
1265 feet_y
1266 );
1267 page_nodes.push(Node::Path {
1268 id: String::new(),
1269 d: body_d,
1270 fill_rule: String::new(),
1271 fill: Paint::None,
1272 stroke: Stroke {
1273 paint: Paint::solid("#2563eb"),
1274 width: 1.6,
1275 ..Default::default()
1276 },
1277 transform: IDENTITY,
1278 clip_id: None,
1279 meta: SourceMeta::default(),
1280 });
1281
1282 page_nodes.push(Node::Text {
1284 id: String::new(),
1285 x: center_x,
1286 y: base_y + 56.0,
1287 runs: vec![TextRun {
1288 text: p.label.clone(),
1289 font_size: 12.0,
1290 font_family: "Helvetica, Arial, sans-serif".to_string(),
1291 bold: true,
1292 fill: Paint::solid("#0f172a"),
1293 ..Default::default()
1294 }],
1295 anchor: TextAnchor::Middle,
1296 transform: IDENTITY,
1297 opacity: 1.0,
1298 stroke: Stroke::default(),
1299 clip_id: None,
1300 meta: SourceMeta::default(),
1301 });
1302 } else {
1303 let box_h = 36.0;
1305 let box_x = center_x - w / 2.0;
1306 let box_y = base_y + (header_height - box_h) / 2.0;
1307 let box_d = format!(
1308 "M {:.1},{:.1} h {:.1} a 4 4 0 0 1 4 4 v {:.1} a 4 4 0 0 1 -4 4 h -{:.1} a 4 4 0 0 1 -4 -4 v -{:.1} a 4 4 0 0 1 4 -4 Z",
1309 box_x + 4.0,
1310 box_y,
1311 w - 8.0,
1312 box_h - 8.0,
1313 w - 8.0,
1314 box_h - 8.0
1315 );
1316 page_nodes.push(Node::Path {
1317 id: String::new(),
1318 d: box_d,
1319 fill_rule: String::new(),
1320 fill: Paint::solid("#f1f5f9"),
1321 stroke: Stroke {
1322 paint: Paint::solid("#2563eb"),
1323 width: 1.5,
1324 ..Default::default()
1325 },
1326 transform: IDENTITY,
1327 clip_id: None,
1328 meta: SourceMeta::default(),
1329 });
1330
1331 page_nodes.push(Node::Text {
1333 id: String::new(),
1334 x: center_x,
1335 y: box_y + box_h / 2.0 + 4.0,
1336 runs: vec![TextRun {
1337 text: p.label.clone(),
1338 font_size: 12.0,
1339 font_family: "Helvetica, Arial, sans-serif".to_string(),
1340 bold: true,
1341 fill: Paint::solid("#0f172a"),
1342 ..Default::default()
1343 }],
1344 anchor: TextAnchor::Middle,
1345 transform: IDENTITY,
1346 opacity: 1.0,
1347 stroke: Stroke::default(),
1348 clip_id: None,
1349 meta: SourceMeta::default(),
1350 });
1351 }
1352 };
1353
1354 draw_participant(true, &mut page.nodes);
1355 draw_participant(false, &mut page.nodes);
1356 }
1357
1358 let mut cur_msg_y = lifeline_top + message_gap;
1360 for (msg_idx, msg) in seq.messages.iter().enumerate() {
1361 if let (Some(&x1), Some(&x2)) = (
1362 participant_centers.get(&msg.from),
1363 participant_centers.get(&msg.to),
1364 ) {
1365 let display_text = if seq.has_autonumber {
1366 format!("{}: {}", msg_idx + 1, msg.text)
1367 } else {
1368 msg.text.clone()
1369 };
1370
1371 let is_self = (x1 - x2).abs() < 1.0;
1372 if is_self {
1373 let loop_w = 36.0;
1375 let loop_h = 22.0;
1376 let arrow_end_x = x1 + 2.0;
1377 let arrow_end_y = cur_msg_y + loop_h;
1378
1379 let loop_d = format!(
1380 "M {:.1},{:.1} h {:.1} v {:.1} L {:.1},{:.1}",
1381 x1, cur_msg_y, loop_w, loop_h, arrow_end_x, arrow_end_y
1382 );
1383 let mut stroke = Stroke {
1384 paint: Paint::solid("#334155"),
1385 width: 1.5,
1386 ..Default::default()
1387 };
1388 if msg.is_dotted {
1389 stroke.dash_array = vec![4.0, 3.0];
1390 }
1391 page.nodes.push(Node::Path {
1392 id: String::new(),
1393 d: loop_d,
1394 fill_rule: String::new(),
1395 fill: Paint::None,
1396 stroke,
1397 transform: IDENTITY,
1398 clip_id: None,
1399 meta: SourceMeta::default(),
1400 });
1401
1402 let head_d = format!(
1404 "M {:.1},{:.1} L {:.1},{:.1} L {:.1},{:.1} Z",
1405 arrow_end_x,
1406 arrow_end_y,
1407 arrow_end_x + 6.0,
1408 arrow_end_y - 3.5,
1409 arrow_end_x + 6.0,
1410 arrow_end_y + 3.5
1411 );
1412 page.nodes.push(Node::Path {
1413 id: String::new(),
1414 d: head_d,
1415 fill_rule: String::new(),
1416 fill: Paint::solid("#334155"),
1417 stroke: Stroke::default(),
1418 transform: IDENTITY,
1419 clip_id: None,
1420 meta: SourceMeta::default(),
1421 });
1422
1423 page.nodes.push(Node::Text {
1425 id: String::new(),
1426 x: x1 + 8.0,
1427 y: cur_msg_y - 5.0,
1428 runs: vec![TextRun {
1429 text: display_text,
1430 font_size: 11.0,
1431 font_family: "Helvetica, Arial, sans-serif".to_string(),
1432 fill: Paint::solid("#1e293b"),
1433 ..Default::default()
1434 }],
1435 anchor: TextAnchor::Start,
1436 transform: IDENTITY,
1437 opacity: 1.0,
1438 stroke: Stroke::default(),
1439 clip_id: None,
1440 meta: SourceMeta::default(),
1441 });
1442 } else {
1443 let is_forward = x2 >= x1;
1444 let arrow_end_x = if is_forward { x2 - 2.0 } else { x2 + 2.0 };
1445
1446 let arrow_line_d = format!(
1448 "M {:.1},{:.1} L {:.1},{:.1}",
1449 x1, cur_msg_y, arrow_end_x, cur_msg_y
1450 );
1451 let mut stroke = Stroke {
1452 paint: Paint::solid("#334155"),
1453 width: 1.5,
1454 ..Default::default()
1455 };
1456 if msg.is_dotted {
1457 stroke.dash_array = vec![4.0, 3.0];
1458 }
1459 page.nodes.push(Node::Path {
1460 id: String::new(),
1461 d: arrow_line_d,
1462 fill_rule: String::new(),
1463 fill: Paint::None,
1464 stroke,
1465 transform: IDENTITY,
1466 clip_id: None,
1467 meta: SourceMeta::default(),
1468 });
1469
1470 let head_d = if is_forward {
1472 format!(
1473 "M {:.1},{:.1} L {:.1},{:.1} L {:.1},{:.1} Z",
1474 arrow_end_x,
1475 cur_msg_y,
1476 arrow_end_x - 6.0,
1477 cur_msg_y - 3.5,
1478 arrow_end_x - 6.0,
1479 cur_msg_y + 3.5
1480 )
1481 } else {
1482 format!(
1483 "M {:.1},{:.1} L {:.1},{:.1} L {:.1},{:.1} Z",
1484 arrow_end_x,
1485 cur_msg_y,
1486 arrow_end_x + 6.0,
1487 cur_msg_y - 3.5,
1488 arrow_end_x + 6.0,
1489 cur_msg_y + 3.5
1490 )
1491 };
1492 page.nodes.push(Node::Path {
1493 id: String::new(),
1494 d: head_d,
1495 fill_rule: String::new(),
1496 fill: Paint::solid("#334155"),
1497 stroke: Stroke::default(),
1498 transform: IDENTITY,
1499 clip_id: None,
1500 meta: SourceMeta::default(),
1501 });
1502
1503 let mid_x = (x1 + x2) / 2.0;
1505 page.nodes.push(Node::Text {
1506 id: String::new(),
1507 x: mid_x,
1508 y: cur_msg_y - 6.0,
1509 runs: vec![TextRun {
1510 text: display_text,
1511 font_size: 11.0,
1512 font_family: "Helvetica, Arial, sans-serif".to_string(),
1513 fill: Paint::solid("#1e293b"),
1514 ..Default::default()
1515 }],
1516 anchor: TextAnchor::Middle,
1517 transform: IDENTITY,
1518 opacity: 1.0,
1519 stroke: Stroke::default(),
1520 clip_id: None,
1521 meta: SourceMeta::default(),
1522 });
1523 }
1524
1525 cur_msg_y += message_gap;
1526 }
1527 }
1528
1529 Ok(page)
1530}