Skip to main content

document_svg/diagram/
mod.rs

1//! Diagram parsing (Graphviz DOT and Mermaid), hierarchical layout, and reverse extraction.
2
3pub 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
98/// Parses Graphviz DOT syntax subset
99pub 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    // Check digraph / graph
114    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    // Inside graph body
129    while i < tokens.len() {
130        let tok = &tokens[i];
131        if tok == "}" {
132            break;
133        }
134
135        // Edge: A -> B
136        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
212/// Parses Mermaid flowchart syntax subset
213pub 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            // Check for `A -- text --> B` inline arrow label
252            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            // Check for `-->|text| B` pipe label
266            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            // If there is another arrow in post_label_part, the target node of this segment
281            // is the part before that next arrow, and current_segment continues from post_label_part.
282            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
543/// Computes a hierarchical DAG layout (layered placement) and renders into a Page IR.
544pub 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    // Draw edges first
648    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            // Arrow head (only for directed graphs)
688            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            // Edge label if present
712            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    // Draw nodes
738    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            // Node text label
896            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
923/// Reverse extraction: restores Graphviz DOT or Mermaid from SVG.
924pub 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    // 1. Check content attribute
929    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    // 2. Geometric fallback
936    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        // Check arrow syntax: A->>B: text or A-->>B: text or A->B or A-->B
1075        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    // Calculate dynamic widths based on label lengths
1163    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    // 1. Draw participants (Top and Bottom) + Lifelines
1191    for (p, &w) in participants.iter().zip(widths.iter()) {
1192        let center_x = *participant_centers.get(&p.label).unwrap();
1193
1194        // Lifeline (dashed vertical line)
1195        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        // Helper to draw participant at top or bottom
1216        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                // Draw stick figure
1221                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                // Body: torso, arms, legs
1243                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                // Text under actor
1283                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                // Rounded participant box
1304                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                // Text centered in box
1332                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    // 2. Draw Messages
1359    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                // Self-loopback arrow
1374                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                // Arrow head pointing left
1403                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                // Text to the right of the lifeline
1424                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                // Message Arrow Line
1447                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                // Arrow head
1471                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                // Message text label above line
1504                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}