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.
Files changed (47) hide show
  1. termaid/__init__.py +178 -0
  2. termaid/__main__.py +4 -0
  3. termaid/cli.py +129 -0
  4. termaid/graph/__init__.py +4 -0
  5. termaid/graph/model.py +185 -0
  6. termaid/graph/shapes.py +22 -0
  7. termaid/layout/__init__.py +3 -0
  8. termaid/layout/grid.py +879 -0
  9. termaid/model/__init__.py +0 -0
  10. termaid/model/blockdiagram.py +30 -0
  11. termaid/model/classdiagram.py +47 -0
  12. termaid/model/erdiagram.py +41 -0
  13. termaid/model/gitgraph.py +30 -0
  14. termaid/model/sequence.py +66 -0
  15. termaid/output/__init__.py +3 -0
  16. termaid/output/rich.py +202 -0
  17. termaid/output/text.py +16 -0
  18. termaid/output/widget.py +42 -0
  19. termaid/parser/__init__.py +4 -0
  20. termaid/parser/blockdiagram.py +299 -0
  21. termaid/parser/classdiagram.py +255 -0
  22. termaid/parser/erdiagram.py +246 -0
  23. termaid/parser/flowchart.py +796 -0
  24. termaid/parser/gitgraph.py +328 -0
  25. termaid/parser/sequence.py +249 -0
  26. termaid/parser/statediagram.py +203 -0
  27. termaid/parser/tokens.py +53 -0
  28. termaid/py.typed +0 -0
  29. termaid/renderer/__init__.py +3 -0
  30. termaid/renderer/blockdiagram.py +463 -0
  31. termaid/renderer/canvas.py +254 -0
  32. termaid/renderer/charset.py +130 -0
  33. termaid/renderer/classdiagram.py +724 -0
  34. termaid/renderer/draw.py +662 -0
  35. termaid/renderer/erdiagram.py +497 -0
  36. termaid/renderer/gitgraph.py +414 -0
  37. termaid/renderer/sequence.py +860 -0
  38. termaid/renderer/shapes/__init__.py +456 -0
  39. termaid/renderer/themes.py +87 -0
  40. termaid/routing/__init__.py +3 -0
  41. termaid/routing/pathfinder.py +156 -0
  42. termaid/routing/router.py +281 -0
  43. termaid-0.1.1.dist-info/METADATA +416 -0
  44. termaid-0.1.1.dist-info/RECORD +47 -0
  45. termaid-0.1.1.dist-info/WHEEL +4 -0
  46. termaid-0.1.1.dist-info/entry_points.txt +2 -0
  47. 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
@@ -0,0 +1,4 @@
1
+ """Allow running termaid as a module: python -m termaid"""
2
+ from .cli import main
3
+
4
+ main()
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())
@@ -0,0 +1,4 @@
1
+ from .model import Graph, Node, Edge, Subgraph
2
+ from .shapes import NodeShape
3
+
4
+ __all__ = ["Graph", "Node", "Edge", "Subgraph", "NodeShape"]
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)
@@ -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 ━━━)
@@ -0,0 +1,3 @@
1
+ from .grid import GridLayout
2
+
3
+ __all__ = ["GridLayout"]