termaid 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termaid/__init__.py +178 -0
- termaid/__main__.py +4 -0
- termaid/cli.py +129 -0
- termaid/graph/__init__.py +4 -0
- termaid/graph/model.py +185 -0
- termaid/graph/shapes.py +22 -0
- termaid/layout/__init__.py +3 -0
- termaid/layout/grid.py +879 -0
- termaid/model/__init__.py +0 -0
- termaid/model/blockdiagram.py +30 -0
- termaid/model/classdiagram.py +47 -0
- termaid/model/erdiagram.py +41 -0
- termaid/model/gitgraph.py +30 -0
- termaid/model/sequence.py +66 -0
- termaid/output/__init__.py +3 -0
- termaid/output/rich.py +202 -0
- termaid/output/text.py +16 -0
- termaid/output/widget.py +42 -0
- termaid/parser/__init__.py +4 -0
- termaid/parser/blockdiagram.py +299 -0
- termaid/parser/classdiagram.py +255 -0
- termaid/parser/erdiagram.py +246 -0
- termaid/parser/flowchart.py +796 -0
- termaid/parser/gitgraph.py +328 -0
- termaid/parser/sequence.py +249 -0
- termaid/parser/statediagram.py +203 -0
- termaid/parser/tokens.py +53 -0
- termaid/py.typed +0 -0
- termaid/renderer/__init__.py +3 -0
- termaid/renderer/blockdiagram.py +463 -0
- termaid/renderer/canvas.py +254 -0
- termaid/renderer/charset.py +130 -0
- termaid/renderer/classdiagram.py +724 -0
- termaid/renderer/draw.py +662 -0
- termaid/renderer/erdiagram.py +497 -0
- termaid/renderer/gitgraph.py +414 -0
- termaid/renderer/sequence.py +860 -0
- termaid/renderer/shapes/__init__.py +456 -0
- termaid/renderer/themes.py +87 -0
- termaid/routing/__init__.py +3 -0
- termaid/routing/pathfinder.py +156 -0
- termaid/routing/router.py +281 -0
- termaid-0.1.1.dist-info/METADATA +416 -0
- termaid-0.1.1.dist-info/RECORD +47 -0
- termaid-0.1.1.dist-info/WHEEL +4 -0
- termaid-0.1.1.dist-info/entry_points.txt +2 -0
- termaid-0.1.1.dist-info/licenses/LICENSE +21 -0
termaid/__init__.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""termaid - Render Mermaid diagram syntax as beautiful Unicode art in the terminal."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .graph.model import Graph
|
|
7
|
+
from .parser.flowchart import parse_flowchart
|
|
8
|
+
from .parser.statediagram import parse_state_diagram
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _strip_frontmatter(text: str) -> str:
|
|
17
|
+
"""Strip YAML frontmatter (---...---) from the beginning of mermaid source."""
|
|
18
|
+
return _FRONTMATTER_RE.sub("", text)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse(source: str) -> Graph:
|
|
22
|
+
"""Parse mermaid syntax and return a Graph model.
|
|
23
|
+
|
|
24
|
+
Auto-detects diagram type (flowchart or state diagram).
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
source: Mermaid diagram source text
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Parsed Graph model
|
|
31
|
+
"""
|
|
32
|
+
text = _strip_frontmatter(source.strip())
|
|
33
|
+
if text.startswith("stateDiagram"):
|
|
34
|
+
return parse_state_diagram(text)
|
|
35
|
+
return parse_flowchart(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render(
|
|
39
|
+
source: str,
|
|
40
|
+
*,
|
|
41
|
+
use_ascii: bool = False,
|
|
42
|
+
padding_x: int = 4,
|
|
43
|
+
padding_y: int = 2,
|
|
44
|
+
rounded_edges: bool = True,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Render mermaid syntax as Unicode (or ASCII) art.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
source: Mermaid diagram source text
|
|
50
|
+
use_ascii: Use ASCII characters instead of Unicode box-drawing
|
|
51
|
+
padding_x: Horizontal padding inside node boxes
|
|
52
|
+
padding_y: Vertical padding inside node boxes
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Rendered diagram as a string
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> from termaid import render
|
|
59
|
+
>>> print(render("graph LR\\n A --> B --> C"))
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
text = _strip_frontmatter(source.strip())
|
|
63
|
+
if text.startswith("sequenceDiagram"):
|
|
64
|
+
from .parser.sequence import parse_sequence_diagram
|
|
65
|
+
from .renderer.sequence import render_sequence
|
|
66
|
+
diagram = parse_sequence_diagram(text)
|
|
67
|
+
return render_sequence(diagram, use_ascii=use_ascii).to_string()
|
|
68
|
+
|
|
69
|
+
if text.startswith("classDiagram"):
|
|
70
|
+
from .parser.classdiagram import parse_class_diagram
|
|
71
|
+
from .renderer.classdiagram import render_class_diagram
|
|
72
|
+
diagram = parse_class_diagram(text)
|
|
73
|
+
return render_class_diagram(diagram, use_ascii=use_ascii).to_string()
|
|
74
|
+
|
|
75
|
+
if text.startswith("erDiagram"):
|
|
76
|
+
from .parser.erdiagram import parse_er_diagram
|
|
77
|
+
from .renderer.erdiagram import render_er_diagram
|
|
78
|
+
diagram = parse_er_diagram(text)
|
|
79
|
+
return render_er_diagram(diagram, use_ascii=use_ascii).to_string()
|
|
80
|
+
|
|
81
|
+
if text.startswith("block"):
|
|
82
|
+
from .parser.blockdiagram import parse_block_diagram
|
|
83
|
+
from .renderer.blockdiagram import render_block_diagram
|
|
84
|
+
diagram = parse_block_diagram(text)
|
|
85
|
+
return render_block_diagram(diagram, use_ascii=use_ascii).to_string()
|
|
86
|
+
|
|
87
|
+
if text.startswith("gitGraph") or (text.startswith("%%{init") and "gitGraph" in text):
|
|
88
|
+
from .parser.gitgraph import parse_git_graph
|
|
89
|
+
from .renderer.gitgraph import render_git_graph
|
|
90
|
+
diagram = parse_git_graph(text)
|
|
91
|
+
return render_git_graph(diagram, use_ascii=use_ascii).to_string()
|
|
92
|
+
|
|
93
|
+
graph = parse(text)
|
|
94
|
+
from .output.text import render_text
|
|
95
|
+
return render_text(graph, use_ascii=use_ascii, padding_x=padding_x, padding_y=padding_y, rounded_edges=rounded_edges)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
return f"[termaid] Failed to render diagram: {exc}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_rich(
|
|
101
|
+
source: str,
|
|
102
|
+
*,
|
|
103
|
+
use_ascii: bool = False,
|
|
104
|
+
padding_x: int = 4,
|
|
105
|
+
padding_y: int = 2,
|
|
106
|
+
rounded_edges: bool = True,
|
|
107
|
+
theme: str = "default",
|
|
108
|
+
):
|
|
109
|
+
"""Render mermaid syntax as a Rich Text object with colors.
|
|
110
|
+
|
|
111
|
+
Requires: pip install termaid[rich]
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
source: Mermaid diagram source text
|
|
115
|
+
use_ascii: Use ASCII characters instead of Unicode
|
|
116
|
+
padding_x: Horizontal padding inside node boxes
|
|
117
|
+
padding_y: Vertical padding inside node boxes
|
|
118
|
+
theme: Color theme name (default, terra, neon, mono, amber, phosphor)
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
rich.text.Text object
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
text = _strip_frontmatter(source.strip())
|
|
125
|
+
if text.startswith("sequenceDiagram"):
|
|
126
|
+
from .parser.sequence import parse_sequence_diagram
|
|
127
|
+
from .renderer.sequence import render_sequence
|
|
128
|
+
from .output.rich import render_sequence_rich
|
|
129
|
+
diagram = parse_sequence_diagram(text)
|
|
130
|
+
canvas = render_sequence(diagram, use_ascii=use_ascii)
|
|
131
|
+
return render_sequence_rich(canvas, theme=theme)
|
|
132
|
+
|
|
133
|
+
if text.startswith("classDiagram"):
|
|
134
|
+
from .parser.classdiagram import parse_class_diagram
|
|
135
|
+
from .renderer.classdiagram import render_class_diagram
|
|
136
|
+
from .output.rich import render_sequence_rich
|
|
137
|
+
diagram = parse_class_diagram(text)
|
|
138
|
+
canvas = render_class_diagram(diagram, use_ascii=use_ascii)
|
|
139
|
+
return render_sequence_rich(canvas, theme=theme)
|
|
140
|
+
|
|
141
|
+
if text.startswith("erDiagram"):
|
|
142
|
+
from .parser.erdiagram import parse_er_diagram
|
|
143
|
+
from .renderer.erdiagram import render_er_diagram
|
|
144
|
+
from .output.rich import render_sequence_rich
|
|
145
|
+
diagram = parse_er_diagram(text)
|
|
146
|
+
canvas = render_er_diagram(diagram, use_ascii=use_ascii)
|
|
147
|
+
return render_sequence_rich(canvas, theme=theme)
|
|
148
|
+
|
|
149
|
+
if text.startswith("block"):
|
|
150
|
+
from .parser.blockdiagram import parse_block_diagram
|
|
151
|
+
from .renderer.blockdiagram import render_block_diagram
|
|
152
|
+
from .output.rich import render_sequence_rich
|
|
153
|
+
diagram = parse_block_diagram(text)
|
|
154
|
+
canvas = render_block_diagram(diagram, use_ascii=use_ascii)
|
|
155
|
+
return render_sequence_rich(canvas, theme=theme)
|
|
156
|
+
|
|
157
|
+
if text.startswith("gitGraph") or (text.startswith("%%{init") and "gitGraph" in text):
|
|
158
|
+
from .parser.gitgraph import parse_git_graph
|
|
159
|
+
from .renderer.gitgraph import render_git_graph
|
|
160
|
+
from .output.rich import render_sequence_rich
|
|
161
|
+
diagram = parse_git_graph(text)
|
|
162
|
+
canvas = render_git_graph(diagram, use_ascii=use_ascii)
|
|
163
|
+
return render_sequence_rich(canvas, theme=theme)
|
|
164
|
+
|
|
165
|
+
graph = parse(text)
|
|
166
|
+
from .output.rich import render_rich as _render_rich
|
|
167
|
+
return _render_rich(graph, use_ascii=use_ascii, padding_x=padding_x, padding_y=padding_y, rounded_edges=rounded_edges, theme=theme)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
from rich.text import Text
|
|
170
|
+
return Text(f"[termaid] Failed to render diagram: {exc}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Lazy import for MermaidWidget
|
|
174
|
+
def __getattr__(name: str):
|
|
175
|
+
if name == "MermaidWidget":
|
|
176
|
+
from .output.widget import _get_widget_class
|
|
177
|
+
return _get_widget_class()
|
|
178
|
+
raise AttributeError(f"module 'termaid' has no attribute {name!r}")
|
termaid/__main__.py
ADDED
termaid/cli.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""CLI entry point for termaid."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv: list[str] | None = None) -> int:
|
|
10
|
+
"""Main CLI entry point."""
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="termaid",
|
|
13
|
+
description="Render Mermaid diagrams as Unicode art in the terminal",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"file",
|
|
17
|
+
nargs="?",
|
|
18
|
+
help="Mermaid diagram file (.mmd). Reads from stdin if not provided.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--ascii",
|
|
22
|
+
action="store_true",
|
|
23
|
+
help="Use ASCII characters instead of Unicode box-drawing",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--padding-x",
|
|
27
|
+
type=int,
|
|
28
|
+
default=4,
|
|
29
|
+
help="Horizontal padding inside node boxes (default: 4)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--padding-y",
|
|
33
|
+
type=int,
|
|
34
|
+
default=2,
|
|
35
|
+
help="Vertical padding inside node boxes (default: 2)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--sharp-edges",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Use sharp corners on edge turns (┌┐└┘) instead of rounded (╭╮╰╯)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--theme",
|
|
44
|
+
default=None,
|
|
45
|
+
choices=["default", "terra", "neon", "mono", "amber", "phosphor"],
|
|
46
|
+
help="Color theme. Requires 'rich' package (pip install termaid[rich]).",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--version",
|
|
50
|
+
action="version",
|
|
51
|
+
version="%(prog)s 0.1.0",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
args = parser.parse_args(argv)
|
|
55
|
+
|
|
56
|
+
use_color = args.theme is not None
|
|
57
|
+
theme = args.theme or "default"
|
|
58
|
+
|
|
59
|
+
# Read input
|
|
60
|
+
if args.file:
|
|
61
|
+
try:
|
|
62
|
+
with open(args.file) as f:
|
|
63
|
+
source = f.read()
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
except OSError as e:
|
|
68
|
+
print(f"Error reading file: {e}", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
elif not sys.stdin.isatty():
|
|
71
|
+
source = sys.stdin.read()
|
|
72
|
+
else:
|
|
73
|
+
print("Error: No input provided. Pass a file or pipe input.", file=sys.stderr)
|
|
74
|
+
print("Usage: termaid diagram.mmd", file=sys.stderr)
|
|
75
|
+
print(" echo 'graph LR; A-->B' | termaid", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
source = source.strip()
|
|
79
|
+
if not source:
|
|
80
|
+
print("Error: Empty input.", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
from termaid import render, render_rich
|
|
85
|
+
except ImportError:
|
|
86
|
+
# Support running directly: python3 src/termaid/cli.py
|
|
87
|
+
# __file__ is src/termaid/cli.py → go up to src/
|
|
88
|
+
_src_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
89
|
+
sys.path.insert(0, _src_dir)
|
|
90
|
+
try:
|
|
91
|
+
from termaid import render, render_rich
|
|
92
|
+
except ImportError:
|
|
93
|
+
print("Error: termaid package not found. Install with: pip install -e .", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
if use_color:
|
|
98
|
+
result = render_rich(
|
|
99
|
+
source,
|
|
100
|
+
use_ascii=args.ascii,
|
|
101
|
+
padding_x=args.padding_x,
|
|
102
|
+
padding_y=args.padding_y,
|
|
103
|
+
rounded_edges=not args.sharp_edges,
|
|
104
|
+
theme=theme,
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
from rich import print as rprint
|
|
108
|
+
rprint(result)
|
|
109
|
+
except ImportError:
|
|
110
|
+
print("Error: 'rich' package required for --theme. Install with: pip install termaid[rich]", file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
else:
|
|
113
|
+
result = render(
|
|
114
|
+
source,
|
|
115
|
+
use_ascii=args.ascii,
|
|
116
|
+
padding_x=args.padding_x,
|
|
117
|
+
padding_y=args.padding_y,
|
|
118
|
+
rounded_edges=not args.sharp_edges,
|
|
119
|
+
)
|
|
120
|
+
print(result)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"Error rendering diagram: {e}", file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
termaid/graph/model.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
|
|
6
|
+
from .shapes import NodeShape
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class GraphNote:
|
|
11
|
+
"""A note attached to a node in the graph."""
|
|
12
|
+
text: str
|
|
13
|
+
position: str # "rightof" or "leftof"
|
|
14
|
+
target: str # node id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class LabelSegment:
|
|
19
|
+
"""A segment of a node label with optional bold/italic styling."""
|
|
20
|
+
text: str
|
|
21
|
+
bold: bool = False
|
|
22
|
+
italic: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ArrowType(Enum):
|
|
26
|
+
ARROW = auto() # --> (filled triangle ►▼◄▲)
|
|
27
|
+
CIRCLE = auto() # --o (○)
|
|
28
|
+
CROSS = auto() # --x (×)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EdgeStyle(Enum):
|
|
32
|
+
SOLID = auto() # -->
|
|
33
|
+
DOTTED = auto() # -.->
|
|
34
|
+
THICK = auto() # ==>
|
|
35
|
+
INVISIBLE = auto() # ~~~
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Direction(Enum):
|
|
39
|
+
TB = "TB"
|
|
40
|
+
TD = "TD"
|
|
41
|
+
LR = "LR"
|
|
42
|
+
BT = "BT"
|
|
43
|
+
RL = "RL"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_vertical(self) -> bool:
|
|
47
|
+
return self in (Direction.TB, Direction.TD, Direction.BT)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_horizontal(self) -> bool:
|
|
51
|
+
return self in (Direction.LR, Direction.RL)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_reversed(self) -> bool:
|
|
55
|
+
return self in (Direction.BT, Direction.RL)
|
|
56
|
+
|
|
57
|
+
def normalized(self) -> Direction:
|
|
58
|
+
"""Return the non-reversed equivalent (BT->TB, RL->LR)."""
|
|
59
|
+
if self == Direction.BT:
|
|
60
|
+
return Direction.TB
|
|
61
|
+
if self == Direction.RL:
|
|
62
|
+
return Direction.LR
|
|
63
|
+
if self == Direction.TD:
|
|
64
|
+
return Direction.TB
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Node:
|
|
70
|
+
id: str
|
|
71
|
+
label: str
|
|
72
|
+
shape: NodeShape = NodeShape.RECTANGLE
|
|
73
|
+
style_class: str | None = None
|
|
74
|
+
label_segments: list[LabelSegment] | None = None
|
|
75
|
+
|
|
76
|
+
def __hash__(self) -> int:
|
|
77
|
+
return hash(self.id)
|
|
78
|
+
|
|
79
|
+
def __eq__(self, other: object) -> bool:
|
|
80
|
+
if isinstance(other, Node):
|
|
81
|
+
return self.id == other.id
|
|
82
|
+
return NotImplemented
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Edge:
|
|
87
|
+
source: str
|
|
88
|
+
target: str
|
|
89
|
+
label: str = ""
|
|
90
|
+
style: EdgeStyle = EdgeStyle.SOLID
|
|
91
|
+
has_arrow_start: bool = False
|
|
92
|
+
has_arrow_end: bool = True
|
|
93
|
+
arrow_type_start: ArrowType = ArrowType.ARROW
|
|
94
|
+
arrow_type_end: ArrowType = ArrowType.ARROW
|
|
95
|
+
min_length: int = 1
|
|
96
|
+
source_is_subgraph: bool = False
|
|
97
|
+
target_is_subgraph: bool = False
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_bidirectional(self) -> bool:
|
|
101
|
+
return self.has_arrow_start and self.has_arrow_end
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def is_self_reference(self) -> bool:
|
|
105
|
+
return self.source == self.target
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Subgraph:
|
|
110
|
+
id: str
|
|
111
|
+
label: str
|
|
112
|
+
node_ids: list[str] = field(default_factory=list)
|
|
113
|
+
children: list[Subgraph] = field(default_factory=list)
|
|
114
|
+
direction: Direction | None = None
|
|
115
|
+
parent: Subgraph | None = field(default=None, repr=False)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Graph:
|
|
120
|
+
direction: Direction = Direction.TB
|
|
121
|
+
nodes: dict[str, Node] = field(default_factory=dict)
|
|
122
|
+
edges: list[Edge] = field(default_factory=list)
|
|
123
|
+
subgraphs: list[Subgraph] = field(default_factory=list)
|
|
124
|
+
node_order: list[str] = field(default_factory=list)
|
|
125
|
+
class_defs: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
126
|
+
node_styles: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
127
|
+
link_styles: dict[int, dict[str, str]] = field(default_factory=dict)
|
|
128
|
+
warnings: list[str] = field(default_factory=list)
|
|
129
|
+
notes: list[GraphNote] = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
def add_node(self, node: Node) -> None:
|
|
132
|
+
if node.id not in self.nodes:
|
|
133
|
+
self.nodes[node.id] = node
|
|
134
|
+
self.node_order.append(node.id)
|
|
135
|
+
else:
|
|
136
|
+
existing = self.nodes[node.id]
|
|
137
|
+
if node.label != node.id and existing.label == existing.id:
|
|
138
|
+
existing.label = node.label
|
|
139
|
+
if node.shape != NodeShape.RECTANGLE:
|
|
140
|
+
existing.shape = node.shape
|
|
141
|
+
if node.style_class:
|
|
142
|
+
existing.style_class = node.style_class
|
|
143
|
+
|
|
144
|
+
def add_edge(self, edge: Edge) -> None:
|
|
145
|
+
self.edges.append(edge)
|
|
146
|
+
|
|
147
|
+
def get_roots(self) -> list[str]:
|
|
148
|
+
"""Nodes with no incoming edges, in definition order."""
|
|
149
|
+
targets = {e.target for e in self.edges}
|
|
150
|
+
roots = [nid for nid in self.node_order if nid not in targets]
|
|
151
|
+
return roots if roots else [self.node_order[0]] if self.node_order else []
|
|
152
|
+
|
|
153
|
+
def get_children(self, node_id: str) -> list[str]:
|
|
154
|
+
"""Get target nodes of outgoing edges from node_id, in order."""
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
children: list[str] = []
|
|
157
|
+
for e in self.edges:
|
|
158
|
+
if e.source == node_id and e.target != node_id and e.target not in seen:
|
|
159
|
+
seen.add(e.target)
|
|
160
|
+
children.append(e.target)
|
|
161
|
+
return children
|
|
162
|
+
|
|
163
|
+
def find_subgraph_by_id(self, sg_id: str) -> Subgraph | None:
|
|
164
|
+
"""Find a subgraph by its ID (recursive search)."""
|
|
165
|
+
def _search(subs: list[Subgraph]) -> Subgraph | None:
|
|
166
|
+
for sg in subs:
|
|
167
|
+
if sg.id == sg_id:
|
|
168
|
+
return sg
|
|
169
|
+
result = _search(sg.children)
|
|
170
|
+
if result:
|
|
171
|
+
return result
|
|
172
|
+
return None
|
|
173
|
+
return _search(self.subgraphs)
|
|
174
|
+
|
|
175
|
+
def find_subgraph_for_node(self, node_id: str) -> Subgraph | None:
|
|
176
|
+
"""Find the innermost subgraph containing a node."""
|
|
177
|
+
def _search(subs: list[Subgraph]) -> Subgraph | None:
|
|
178
|
+
for sg in subs:
|
|
179
|
+
result = _search(sg.children)
|
|
180
|
+
if result:
|
|
181
|
+
return result
|
|
182
|
+
if node_id in sg.node_ids:
|
|
183
|
+
return sg
|
|
184
|
+
return None
|
|
185
|
+
return _search(self.subgraphs)
|
termaid/graph/shapes.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NodeShape(Enum):
|
|
5
|
+
"""Supported node shapes in Mermaid flowcharts."""
|
|
6
|
+
RECTANGLE = auto() # A[text] or plain A
|
|
7
|
+
ROUNDED = auto() # A(text)
|
|
8
|
+
STADIUM = auto() # A([text])
|
|
9
|
+
SUBROUTINE = auto() # A[[text]]
|
|
10
|
+
DIAMOND = auto() # A{text}
|
|
11
|
+
HEXAGON = auto() # A{{text}}
|
|
12
|
+
CIRCLE = auto() # A((text))
|
|
13
|
+
DOUBLE_CIRCLE = auto() # A(((text)))
|
|
14
|
+
ASYMMETRIC = auto() # A>text]
|
|
15
|
+
CYLINDER = auto() # A[(text)]
|
|
16
|
+
PARALLELOGRAM = auto() # A[/text/]
|
|
17
|
+
PARALLELOGRAM_ALT = auto() # A[\text\]
|
|
18
|
+
TRAPEZOID = auto() # A[/text\]
|
|
19
|
+
TRAPEZOID_ALT = auto() # A[\text/]
|
|
20
|
+
START_STATE = auto() # [*] start (filled circle ●)
|
|
21
|
+
END_STATE = auto() # [*] end (bullseye ◉)
|
|
22
|
+
FORK_JOIN = auto() # <<fork>>/<<join>> (thick bar ━━━)
|