- Add beautiful-mermaid library (single-file ASCII renderer) - Update pipeline_viz to generate mermaid graphs and render with beautiful-mermaid - Creates dimensional network visualization with arrows connecting nodes - Animates through effects and highlights active camera mode
4108 lines
126 KiB
Python
4108 lines
126 KiB
Python
#!/usr/bin/env python3
|
||
# ruff: noqa: N815, E402, E741, SIM113
|
||
"""Pure Python Mermaid -> ASCII/Unicode renderer.
|
||
|
||
Vibe-Ported from the TypeScript ASCII renderer from
|
||
https://github.com/lukilabs/beautiful-mermaid/tree/main/src/ascii
|
||
MIT License
|
||
Copyright (c) 2026 Luki Labs
|
||
|
||
Supports:
|
||
- Flowcharts / stateDiagram-v2 (grid + A* pathfinding)
|
||
- sequenceDiagram
|
||
- classDiagram
|
||
- erDiagram
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from dataclasses import dataclass, field
|
||
|
||
# =============================================================================
|
||
# Types
|
||
# =============================================================================
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class GridCoord:
|
||
x: int
|
||
y: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DrawingCoord:
|
||
x: int
|
||
y: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Direction:
|
||
x: int
|
||
y: int
|
||
|
||
|
||
Up = Direction(1, 0)
|
||
Down = Direction(1, 2)
|
||
Left = Direction(0, 1)
|
||
Right = Direction(2, 1)
|
||
UpperRight = Direction(2, 0)
|
||
UpperLeft = Direction(0, 0)
|
||
LowerRight = Direction(2, 2)
|
||
LowerLeft = Direction(0, 2)
|
||
Middle = Direction(1, 1)
|
||
|
||
ALL_DIRECTIONS = [
|
||
Up,
|
||
Down,
|
||
Left,
|
||
Right,
|
||
UpperRight,
|
||
UpperLeft,
|
||
LowerRight,
|
||
LowerLeft,
|
||
Middle,
|
||
]
|
||
|
||
Canvas = list[list[str]]
|
||
|
||
|
||
@dataclass
|
||
class AsciiStyleClass:
|
||
name: str
|
||
styles: dict[str, str]
|
||
|
||
|
||
EMPTY_STYLE = AsciiStyleClass(name="", styles={})
|
||
|
||
|
||
@dataclass
|
||
class AsciiNode:
|
||
name: str
|
||
displayLabel: str
|
||
index: int
|
||
gridCoord: GridCoord | None = None
|
||
drawingCoord: DrawingCoord | None = None
|
||
drawing: Canvas | None = None
|
||
drawn: bool = False
|
||
styleClassName: str = ""
|
||
styleClass: AsciiStyleClass = field(default_factory=lambda: EMPTY_STYLE)
|
||
|
||
|
||
@dataclass
|
||
class AsciiEdge:
|
||
from_node: AsciiNode
|
||
to_node: AsciiNode
|
||
text: str
|
||
path: list[GridCoord] = field(default_factory=list)
|
||
labelLine: list[GridCoord] = field(default_factory=list)
|
||
startDir: Direction = Direction(0, 0)
|
||
endDir: Direction = Direction(0, 0)
|
||
|
||
|
||
@dataclass
|
||
class AsciiSubgraph:
|
||
name: str
|
||
nodes: list[AsciiNode]
|
||
parent: AsciiSubgraph | None
|
||
children: list[AsciiSubgraph]
|
||
direction: str | None = None
|
||
minX: int = 0
|
||
minY: int = 0
|
||
maxX: int = 0
|
||
maxY: int = 0
|
||
|
||
|
||
@dataclass
|
||
class AsciiConfig:
|
||
useAscii: bool
|
||
paddingX: int
|
||
paddingY: int
|
||
boxBorderPadding: int
|
||
graphDirection: str # 'LR' | 'TD'
|
||
|
||
|
||
@dataclass
|
||
class AsciiGraph:
|
||
nodes: list[AsciiNode]
|
||
edges: list[AsciiEdge]
|
||
canvas: Canvas
|
||
grid: dict[str, AsciiNode]
|
||
columnWidth: dict[int, int]
|
||
rowHeight: dict[int, int]
|
||
subgraphs: list[AsciiSubgraph]
|
||
config: AsciiConfig
|
||
offsetX: int = 0
|
||
offsetY: int = 0
|
||
|
||
|
||
# Mermaid parsed types
|
||
|
||
|
||
@dataclass
|
||
class MermaidNode:
|
||
id: str
|
||
label: str
|
||
shape: str
|
||
|
||
|
||
@dataclass
|
||
class MermaidEdge:
|
||
source: str
|
||
target: str
|
||
label: str | None
|
||
style: str
|
||
hasArrowStart: bool
|
||
hasArrowEnd: bool
|
||
|
||
|
||
@dataclass
|
||
class MermaidSubgraph:
|
||
id: str
|
||
label: str
|
||
nodeIds: list[str]
|
||
children: list[MermaidSubgraph]
|
||
direction: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class MermaidGraph:
|
||
direction: str
|
||
nodes: dict[str, MermaidNode]
|
||
edges: list[MermaidEdge]
|
||
subgraphs: list[MermaidSubgraph]
|
||
classDefs: dict[str, dict[str, str]]
|
||
classAssignments: dict[str, str]
|
||
nodeStyles: dict[str, dict[str, str]]
|
||
|
||
|
||
# Sequence types
|
||
|
||
|
||
@dataclass
|
||
class Actor:
|
||
id: str
|
||
label: str
|
||
type: str
|
||
|
||
|
||
@dataclass
|
||
class Message:
|
||
from_id: str
|
||
to_id: str
|
||
label: str
|
||
lineStyle: str
|
||
arrowHead: str
|
||
activate: bool = False
|
||
deactivate: bool = False
|
||
|
||
|
||
@dataclass
|
||
class BlockDivider:
|
||
index: int
|
||
label: str
|
||
|
||
|
||
@dataclass
|
||
class Block:
|
||
type: str
|
||
label: str
|
||
startIndex: int
|
||
endIndex: int
|
||
dividers: list[BlockDivider]
|
||
|
||
|
||
@dataclass
|
||
class Note:
|
||
actorIds: list[str]
|
||
text: str
|
||
position: str
|
||
afterIndex: int
|
||
|
||
|
||
@dataclass
|
||
class SequenceDiagram:
|
||
actors: list[Actor]
|
||
messages: list[Message]
|
||
blocks: list[Block]
|
||
notes: list[Note]
|
||
|
||
|
||
# Class diagram types
|
||
|
||
|
||
@dataclass
|
||
class ClassMember:
|
||
visibility: str
|
||
name: str
|
||
type: str | None = None
|
||
isStatic: bool = False
|
||
isAbstract: bool = False
|
||
|
||
|
||
@dataclass
|
||
class ClassNode:
|
||
id: str
|
||
label: str
|
||
annotation: str | None = None
|
||
attributes: list[ClassMember] = field(default_factory=list)
|
||
methods: list[ClassMember] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class ClassRelationship:
|
||
from_id: str
|
||
to_id: str
|
||
type: str
|
||
markerAt: str
|
||
label: str | None = None
|
||
fromCardinality: str | None = None
|
||
toCardinality: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class ClassNamespace:
|
||
name: str
|
||
classIds: list[str]
|
||
|
||
|
||
@dataclass
|
||
class ClassDiagram:
|
||
classes: list[ClassNode]
|
||
relationships: list[ClassRelationship]
|
||
namespaces: list[ClassNamespace]
|
||
|
||
|
||
# ER types
|
||
|
||
|
||
@dataclass
|
||
class ErAttribute:
|
||
type: str
|
||
name: str
|
||
keys: list[str]
|
||
comment: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class ErEntity:
|
||
id: str
|
||
label: str
|
||
attributes: list[ErAttribute]
|
||
|
||
|
||
@dataclass
|
||
class ErRelationship:
|
||
entity1: str
|
||
entity2: str
|
||
cardinality1: str
|
||
cardinality2: str
|
||
label: str
|
||
identifying: bool
|
||
|
||
|
||
@dataclass
|
||
class ErDiagram:
|
||
entities: list[ErEntity]
|
||
relationships: list[ErRelationship]
|
||
|
||
|
||
# =============================================================================
|
||
# Coordinate helpers
|
||
# =============================================================================
|
||
|
||
|
||
def grid_coord_equals(a: GridCoord, b: GridCoord) -> bool:
|
||
return a.x == b.x and a.y == b.y
|
||
|
||
|
||
def drawing_coord_equals(a: DrawingCoord, b: DrawingCoord) -> bool:
|
||
return a.x == b.x and a.y == b.y
|
||
|
||
|
||
def grid_coord_direction(c: GridCoord, d: Direction) -> GridCoord:
|
||
return GridCoord(c.x + d.x, c.y + d.y)
|
||
|
||
|
||
def grid_key(c: GridCoord) -> str:
|
||
return f"{c.x},{c.y}"
|
||
|
||
|
||
# =============================================================================
|
||
# Canvas
|
||
# =============================================================================
|
||
|
||
|
||
def mk_canvas(x: int, y: int) -> Canvas:
|
||
canvas: Canvas = []
|
||
for _ in range(x + 1):
|
||
canvas.append([" "] * (y + 1))
|
||
return canvas
|
||
|
||
|
||
def get_canvas_size(canvas: Canvas) -> tuple[int, int]:
|
||
return (len(canvas) - 1, (len(canvas[0]) if canvas else 1) - 1)
|
||
|
||
|
||
def copy_canvas(source: Canvas) -> Canvas:
|
||
max_x, max_y = get_canvas_size(source)
|
||
return mk_canvas(max_x, max_y)
|
||
|
||
|
||
def increase_size(canvas: Canvas, new_x: int, new_y: int) -> Canvas:
|
||
curr_x, curr_y = get_canvas_size(canvas)
|
||
target_x = max(new_x, curr_x)
|
||
target_y = max(new_y, curr_y)
|
||
grown = mk_canvas(target_x, target_y)
|
||
for x in range(len(grown)):
|
||
for y in range(len(grown[0])):
|
||
if x < len(canvas) and y < len(canvas[0]):
|
||
grown[x][y] = canvas[x][y]
|
||
canvas[:] = grown
|
||
return canvas
|
||
|
||
|
||
JUNCTION_CHARS = {
|
||
"─",
|
||
"│",
|
||
"┌",
|
||
"┐",
|
||
"└",
|
||
"┘",
|
||
"├",
|
||
"┤",
|
||
"┬",
|
||
"┴",
|
||
"┼",
|
||
"╴",
|
||
"╵",
|
||
"╶",
|
||
"╷",
|
||
}
|
||
|
||
|
||
def is_junction_char(c: str) -> bool:
|
||
return c in JUNCTION_CHARS
|
||
|
||
|
||
JUNCTION_MAP: dict[str, dict[str, str]] = {
|
||
"─": {
|
||
"│": "┼",
|
||
"┌": "┬",
|
||
"┐": "┬",
|
||
"└": "┴",
|
||
"┘": "┴",
|
||
"├": "┼",
|
||
"┤": "┼",
|
||
"┬": "┬",
|
||
"┴": "┴",
|
||
},
|
||
"│": {
|
||
"─": "┼",
|
||
"┌": "├",
|
||
"┐": "┤",
|
||
"└": "├",
|
||
"┘": "┤",
|
||
"├": "├",
|
||
"┤": "┤",
|
||
"┬": "┼",
|
||
"┴": "┼",
|
||
},
|
||
"┌": {
|
||
"─": "┬",
|
||
"│": "├",
|
||
"┐": "┬",
|
||
"└": "├",
|
||
"┘": "┼",
|
||
"├": "├",
|
||
"┤": "┼",
|
||
"┬": "┬",
|
||
"┴": "┼",
|
||
},
|
||
"┐": {
|
||
"─": "┬",
|
||
"│": "┤",
|
||
"┌": "┬",
|
||
"└": "┼",
|
||
"┘": "┤",
|
||
"├": "┼",
|
||
"┤": "┤",
|
||
"┬": "┬",
|
||
"┴": "┼",
|
||
},
|
||
"└": {
|
||
"─": "┴",
|
||
"│": "├",
|
||
"┌": "├",
|
||
"┐": "┼",
|
||
"┘": "┴",
|
||
"├": "├",
|
||
"┤": "┼",
|
||
"┬": "┼",
|
||
"┴": "┴",
|
||
},
|
||
"┘": {
|
||
"─": "┴",
|
||
"│": "┤",
|
||
"┌": "┼",
|
||
"┐": "┤",
|
||
"└": "┴",
|
||
"├": "┼",
|
||
"┤": "┤",
|
||
"┬": "┼",
|
||
"┴": "┴",
|
||
},
|
||
"├": {
|
||
"─": "┼",
|
||
"│": "├",
|
||
"┌": "├",
|
||
"┐": "┼",
|
||
"└": "├",
|
||
"┘": "┼",
|
||
"┤": "┼",
|
||
"┬": "┼",
|
||
"┴": "┼",
|
||
},
|
||
"┤": {
|
||
"─": "┼",
|
||
"│": "┤",
|
||
"┌": "┼",
|
||
"┐": "┤",
|
||
"└": "┼",
|
||
"┘": "┤",
|
||
"├": "┼",
|
||
"┬": "┼",
|
||
"┴": "┼",
|
||
},
|
||
"┬": {
|
||
"─": "┬",
|
||
"│": "┼",
|
||
"┌": "┬",
|
||
"┐": "┬",
|
||
"└": "┼",
|
||
"┘": "┼",
|
||
"├": "┼",
|
||
"┤": "┼",
|
||
"┴": "┼",
|
||
},
|
||
"┴": {
|
||
"─": "┴",
|
||
"│": "┼",
|
||
"┌": "┼",
|
||
"┐": "┼",
|
||
"└": "┴",
|
||
"┘": "┴",
|
||
"├": "┼",
|
||
"┤": "┼",
|
||
"┬": "┼",
|
||
},
|
||
}
|
||
|
||
|
||
def merge_junctions(c1: str, c2: str) -> str:
|
||
return JUNCTION_MAP.get(c1, {}).get(c2, c1)
|
||
|
||
|
||
def merge_canvases(
|
||
base: Canvas, offset: DrawingCoord, use_ascii: bool, *overlays: Canvas
|
||
) -> Canvas:
|
||
max_x, max_y = get_canvas_size(base)
|
||
for overlay in overlays:
|
||
ox, oy = get_canvas_size(overlay)
|
||
max_x = max(max_x, ox + offset.x)
|
||
max_y = max(max_y, oy + offset.y)
|
||
|
||
merged = mk_canvas(max_x, max_y)
|
||
|
||
for x in range(max_x + 1):
|
||
for y in range(max_y + 1):
|
||
if x < len(base) and y < len(base[0]):
|
||
merged[x][y] = base[x][y]
|
||
|
||
for overlay in overlays:
|
||
for x in range(len(overlay)):
|
||
for y in range(len(overlay[0])):
|
||
c = overlay[x][y]
|
||
if c != " ":
|
||
mx = x + offset.x
|
||
my = y + offset.y
|
||
current = merged[mx][my]
|
||
if (
|
||
not use_ascii
|
||
and is_junction_char(c)
|
||
and is_junction_char(current)
|
||
):
|
||
merged[mx][my] = merge_junctions(current, c)
|
||
else:
|
||
merged[mx][my] = c
|
||
|
||
return merged
|
||
|
||
|
||
def canvas_to_string(canvas: Canvas) -> str:
|
||
max_x, max_y = get_canvas_size(canvas)
|
||
min_x = max_x + 1
|
||
min_y = max_y + 1
|
||
used_max_x = -1
|
||
used_max_y = -1
|
||
|
||
for x in range(max_x + 1):
|
||
for y in range(max_y + 1):
|
||
if canvas[x][y] != " ":
|
||
min_x = min(min_x, x)
|
||
min_y = min(min_y, y)
|
||
used_max_x = max(used_max_x, x)
|
||
used_max_y = max(used_max_y, y)
|
||
|
||
if used_max_x < 0 or used_max_y < 0:
|
||
return ""
|
||
|
||
lines: list[str] = []
|
||
for y in range(min_y, used_max_y + 1):
|
||
line = "".join(canvas[x][y] for x in range(min_x, used_max_x + 1))
|
||
lines.append(line.rstrip())
|
||
return "\n".join(lines)
|
||
|
||
|
||
VERTICAL_FLIP_MAP = {
|
||
"▲": "▼",
|
||
"▼": "▲",
|
||
"◤": "◣",
|
||
"◣": "◤",
|
||
"◥": "◢",
|
||
"◢": "◥",
|
||
"^": "v",
|
||
"v": "^",
|
||
"┌": "└",
|
||
"└": "┌",
|
||
"┐": "┘",
|
||
"┘": "┐",
|
||
"┬": "┴",
|
||
"┴": "┬",
|
||
"╵": "╷",
|
||
"╷": "╵",
|
||
}
|
||
|
||
|
||
def flip_canvas_vertically(canvas: Canvas) -> Canvas:
|
||
for col in canvas:
|
||
col.reverse()
|
||
for col in canvas:
|
||
for y in range(len(col)):
|
||
flipped = VERTICAL_FLIP_MAP.get(col[y])
|
||
if flipped:
|
||
col[y] = flipped
|
||
return canvas
|
||
|
||
|
||
def draw_text(canvas: Canvas, start: DrawingCoord, text: str) -> None:
|
||
increase_size(canvas, start.x + len(text), start.y)
|
||
for i, ch in enumerate(text):
|
||
canvas[start.x + i][start.y] = ch
|
||
|
||
|
||
def set_canvas_size_to_grid(
|
||
canvas: Canvas, column_width: dict[int, int], row_height: dict[int, int]
|
||
) -> None:
|
||
max_x = 0
|
||
max_y = 0
|
||
for w in column_width.values():
|
||
max_x += w
|
||
for h in row_height.values():
|
||
max_y += h
|
||
increase_size(canvas, max_x, max_y)
|
||
|
||
|
||
# =============================================================================
|
||
# Parser: flowchart + state diagram
|
||
# =============================================================================
|
||
|
||
import re
|
||
|
||
ARROW_REGEX = re.compile(r"^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?")
|
||
|
||
NODE_PATTERNS = [
|
||
(re.compile(r"^([\w-]+)\(\(\((.+?)\)\)\)"), "doublecircle"),
|
||
(re.compile(r"^([\w-]+)\(\[(.+?)\]\)"), "stadium"),
|
||
(re.compile(r"^([\w-]+)\(\((.+?)\)\)"), "circle"),
|
||
(re.compile(r"^([\w-]+)\[\[(.+?)\]\]"), "subroutine"),
|
||
(re.compile(r"^([\w-]+)\[\((.+?)\)\]"), "cylinder"),
|
||
(re.compile(r"^([\w-]+)\[\/(.+?)\\\]"), "trapezoid"),
|
||
(re.compile(r"^([\w-]+)\[\\(.+?)\/\]"), "trapezoid-alt"),
|
||
(re.compile(r"^([\w-]+)>(.+?)\]"), "asymmetric"),
|
||
(re.compile(r"^([\w-]+)\{\{(.+?)\}\}"), "hexagon"),
|
||
(re.compile(r"^([\w-]+)\[(.+?)\]"), "rectangle"),
|
||
(re.compile(r"^([\w-]+)\((.+?)\)"), "rounded"),
|
||
(re.compile(r"^([\w-]+)\{(.+?)\}"), "diamond"),
|
||
]
|
||
|
||
BARE_NODE_REGEX = re.compile(r"^([\w-]+)")
|
||
CLASS_SHORTHAND_REGEX = re.compile(r"^:::([\w][\w-]*)")
|
||
|
||
|
||
def parse_mermaid(text: str) -> MermaidGraph:
|
||
lines = [
|
||
l.strip()
|
||
for l in re.split(r"[\n;]", text)
|
||
if l.strip() and not l.strip().startswith("%%")
|
||
]
|
||
if not lines:
|
||
raise ValueError("Empty mermaid diagram")
|
||
|
||
header = lines[0]
|
||
if re.match(r"^stateDiagram(-v2)?\s*$", header, re.I):
|
||
return parse_state_diagram(lines)
|
||
return parse_flowchart(lines)
|
||
|
||
|
||
def parse_flowchart(lines: list[str]) -> MermaidGraph:
|
||
m = re.match(r"^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$", lines[0], re.I)
|
||
if not m:
|
||
raise ValueError(
|
||
f"Invalid mermaid header: \"{lines[0]}\". Expected 'graph TD', 'flowchart LR', 'stateDiagram-v2', etc."
|
||
)
|
||
direction = m.group(1).upper()
|
||
graph = MermaidGraph(
|
||
direction=direction,
|
||
nodes={},
|
||
edges=[],
|
||
subgraphs=[],
|
||
classDefs={},
|
||
classAssignments={},
|
||
nodeStyles={},
|
||
)
|
||
|
||
subgraph_stack: list[MermaidSubgraph] = []
|
||
|
||
for line in lines[1:]:
|
||
class_def = re.match(r"^classDef\s+(\w+)\s+(.+)$", line)
|
||
if class_def:
|
||
name = class_def.group(1)
|
||
props = parse_style_props(class_def.group(2))
|
||
graph.classDefs[name] = props
|
||
continue
|
||
|
||
class_assign = re.match(r"^class\s+([\w,-]+)\s+(\w+)$", line)
|
||
if class_assign:
|
||
node_ids = [s.strip() for s in class_assign.group(1).split(",")]
|
||
class_name = class_assign.group(2)
|
||
for nid in node_ids:
|
||
graph.classAssignments[nid] = class_name
|
||
continue
|
||
|
||
style_match = re.match(r"^style\s+([\w,-]+)\s+(.+)$", line)
|
||
if style_match:
|
||
node_ids = [s.strip() for s in style_match.group(1).split(",")]
|
||
props = parse_style_props(style_match.group(2))
|
||
for nid in node_ids:
|
||
existing = graph.nodeStyles.get(nid, {})
|
||
existing.update(props)
|
||
graph.nodeStyles[nid] = existing
|
||
continue
|
||
|
||
dir_match = re.match(r"^direction\s+(TD|TB|LR|BT|RL)\s*$", line, re.I)
|
||
if dir_match and subgraph_stack:
|
||
subgraph_stack[-1].direction = dir_match.group(1).upper()
|
||
continue
|
||
|
||
subgraph_match = re.match(r"^subgraph\s+(.+)$", line)
|
||
if subgraph_match:
|
||
rest = subgraph_match.group(1).strip()
|
||
bracket = re.match(r"^([\w-]+)\s*\[(.+)\]$", rest)
|
||
if bracket:
|
||
sg_id = bracket.group(1)
|
||
label = bracket.group(2)
|
||
else:
|
||
label = rest
|
||
sg_id = re.sub(r"[^\w]", "", re.sub(r"\s+", "_", rest))
|
||
sg = MermaidSubgraph(id=sg_id, label=label, nodeIds=[], children=[])
|
||
subgraph_stack.append(sg)
|
||
continue
|
||
|
||
if line == "end":
|
||
completed = subgraph_stack.pop() if subgraph_stack else None
|
||
if completed:
|
||
if subgraph_stack:
|
||
subgraph_stack[-1].children.append(completed)
|
||
else:
|
||
graph.subgraphs.append(completed)
|
||
continue
|
||
|
||
parse_edge_line(line, graph, subgraph_stack)
|
||
|
||
return graph
|
||
|
||
|
||
def parse_state_diagram(lines: list[str]) -> MermaidGraph:
|
||
graph = MermaidGraph(
|
||
direction="TD",
|
||
nodes={},
|
||
edges=[],
|
||
subgraphs=[],
|
||
classDefs={},
|
||
classAssignments={},
|
||
nodeStyles={},
|
||
)
|
||
composite_stack: list[MermaidSubgraph] = []
|
||
start_count = 0
|
||
end_count = 0
|
||
|
||
for line in lines[1:]:
|
||
dir_match = re.match(r"^direction\s+(TD|TB|LR|BT|RL)\s*$", line, re.I)
|
||
if dir_match:
|
||
if composite_stack:
|
||
composite_stack[-1].direction = dir_match.group(1).upper()
|
||
else:
|
||
graph.direction = dir_match.group(1).upper()
|
||
continue
|
||
|
||
comp_match = re.match(r'^state\s+(?:"([^"]+)"\s+as\s+)?(\w+)\s*\{$', line)
|
||
if comp_match:
|
||
label = comp_match.group(1) or comp_match.group(2)
|
||
sg_id = comp_match.group(2)
|
||
sg = MermaidSubgraph(id=sg_id, label=label, nodeIds=[], children=[])
|
||
composite_stack.append(sg)
|
||
continue
|
||
|
||
if line == "}":
|
||
completed = composite_stack.pop() if composite_stack else None
|
||
if completed:
|
||
if composite_stack:
|
||
composite_stack[-1].children.append(completed)
|
||
else:
|
||
graph.subgraphs.append(completed)
|
||
continue
|
||
|
||
alias_match = re.match(r'^state\s+"([^"]+)"\s+as\s+(\w+)\s*$', line)
|
||
if alias_match:
|
||
label = alias_match.group(1)
|
||
sid = alias_match.group(2)
|
||
register_state_node(
|
||
graph,
|
||
composite_stack,
|
||
MermaidNode(id=sid, label=label, shape="rounded"),
|
||
)
|
||
continue
|
||
|
||
trans_match = re.match(
|
||
r"^(\[\*\]|[\w-]+)\s*(-->)\s*(\[\*\]|[\w-]+)(?:\s*:\s*(.+))?$", line
|
||
)
|
||
if trans_match:
|
||
source_id = trans_match.group(1)
|
||
target_id = trans_match.group(3)
|
||
edge_label = (trans_match.group(4) or "").strip() or None
|
||
|
||
if source_id == "[*]":
|
||
start_count += 1
|
||
source_id = f"_start{start_count if start_count > 1 else ''}"
|
||
register_state_node(
|
||
graph,
|
||
composite_stack,
|
||
MermaidNode(id=source_id, label="", shape="state-start"),
|
||
)
|
||
else:
|
||
ensure_state_node(graph, composite_stack, source_id)
|
||
|
||
if target_id == "[*]":
|
||
end_count += 1
|
||
target_id = f"_end{end_count if end_count > 1 else ''}"
|
||
register_state_node(
|
||
graph,
|
||
composite_stack,
|
||
MermaidNode(id=target_id, label="", shape="state-end"),
|
||
)
|
||
else:
|
||
ensure_state_node(graph, composite_stack, target_id)
|
||
|
||
graph.edges.append(
|
||
MermaidEdge(
|
||
source=source_id,
|
||
target=target_id,
|
||
label=edge_label,
|
||
style="solid",
|
||
hasArrowStart=False,
|
||
hasArrowEnd=True,
|
||
)
|
||
)
|
||
continue
|
||
|
||
desc_match = re.match(r"^([\w-]+)\s*:\s*(.+)$", line)
|
||
if desc_match:
|
||
sid = desc_match.group(1)
|
||
label = desc_match.group(2).strip()
|
||
register_state_node(
|
||
graph,
|
||
composite_stack,
|
||
MermaidNode(id=sid, label=label, shape="rounded"),
|
||
)
|
||
continue
|
||
|
||
return graph
|
||
|
||
|
||
def register_state_node(
|
||
graph: MermaidGraph, stack: list[MermaidSubgraph], node: MermaidNode
|
||
) -> None:
|
||
if node.id not in graph.nodes:
|
||
graph.nodes[node.id] = node
|
||
if stack:
|
||
if node.id.startswith(("_start", "_end")):
|
||
return
|
||
current = stack[-1]
|
||
if node.id not in current.nodeIds:
|
||
current.nodeIds.append(node.id)
|
||
|
||
|
||
def ensure_state_node(
|
||
graph: MermaidGraph, stack: list[MermaidSubgraph], node_id: str
|
||
) -> None:
|
||
if node_id not in graph.nodes:
|
||
register_state_node(
|
||
graph, stack, MermaidNode(id=node_id, label=node_id, shape="rounded")
|
||
)
|
||
else:
|
||
if stack:
|
||
if node_id.startswith(("_start", "_end")):
|
||
return
|
||
current = stack[-1]
|
||
if node_id not in current.nodeIds:
|
||
current.nodeIds.append(node_id)
|
||
|
||
|
||
def parse_style_props(props_str: str) -> dict[str, str]:
|
||
props: dict[str, str] = {}
|
||
for pair in props_str.split(","):
|
||
colon = pair.find(":")
|
||
if colon > 0:
|
||
key = pair[:colon].strip()
|
||
val = pair[colon + 1 :].strip()
|
||
if key and val:
|
||
props[key] = val
|
||
return props
|
||
|
||
|
||
def arrow_style_from_op(op: str) -> str:
|
||
if op == "-.->" or op == "-.-":
|
||
return "dotted"
|
||
if op == "==>" or op == "===":
|
||
return "thick"
|
||
return "solid"
|
||
|
||
|
||
def parse_edge_line(
|
||
line: str, graph: MermaidGraph, subgraph_stack: list[MermaidSubgraph]
|
||
) -> None:
|
||
remaining = line.strip()
|
||
first_group = consume_node_group(remaining, graph, subgraph_stack)
|
||
if not first_group or not first_group["ids"]:
|
||
return
|
||
|
||
remaining = first_group["remaining"].strip()
|
||
prev_group_ids = first_group["ids"]
|
||
|
||
while remaining:
|
||
m = ARROW_REGEX.match(remaining)
|
||
if not m:
|
||
break
|
||
|
||
has_arrow_start = bool(m.group(1))
|
||
arrow_op = m.group(2)
|
||
edge_label = (m.group(3) or "").strip() or None
|
||
remaining = remaining[len(m.group(0)) :].strip()
|
||
|
||
style = arrow_style_from_op(arrow_op)
|
||
has_arrow_end = arrow_op.endswith(">")
|
||
|
||
next_group = consume_node_group(remaining, graph, subgraph_stack)
|
||
if not next_group or not next_group["ids"]:
|
||
break
|
||
|
||
remaining = next_group["remaining"].strip()
|
||
|
||
for src in prev_group_ids:
|
||
for tgt in next_group["ids"]:
|
||
graph.edges.append(
|
||
MermaidEdge(
|
||
source=src,
|
||
target=tgt,
|
||
label=edge_label,
|
||
style=style,
|
||
hasArrowStart=has_arrow_start,
|
||
hasArrowEnd=has_arrow_end,
|
||
)
|
||
)
|
||
|
||
prev_group_ids = next_group["ids"]
|
||
|
||
|
||
def consume_node_group(
|
||
text: str, graph: MermaidGraph, subgraph_stack: list[MermaidSubgraph]
|
||
) -> dict[str, object] | None:
|
||
first = consume_node(text, graph, subgraph_stack)
|
||
if not first:
|
||
return None
|
||
|
||
ids = [first["id"]]
|
||
remaining = first["remaining"].strip()
|
||
|
||
while remaining.startswith("&"):
|
||
remaining = remaining[1:].strip()
|
||
nxt = consume_node(remaining, graph, subgraph_stack)
|
||
if not nxt:
|
||
break
|
||
ids.append(nxt["id"])
|
||
remaining = nxt["remaining"].strip()
|
||
|
||
return {"ids": ids, "remaining": remaining}
|
||
|
||
|
||
def consume_node(
|
||
text: str, graph: MermaidGraph, subgraph_stack: list[MermaidSubgraph]
|
||
) -> dict[str, object] | None:
|
||
node_id: str | None = None
|
||
remaining = text
|
||
|
||
for regex, shape in NODE_PATTERNS:
|
||
m = regex.match(text)
|
||
if m:
|
||
node_id = m.group(1)
|
||
label = m.group(2)
|
||
register_node(
|
||
graph, subgraph_stack, MermaidNode(id=node_id, label=label, shape=shape)
|
||
)
|
||
remaining = text[len(m.group(0)) :] # type: ignore[index]
|
||
break
|
||
|
||
if node_id is None:
|
||
m = BARE_NODE_REGEX.match(text)
|
||
if m:
|
||
node_id = m.group(1)
|
||
if node_id not in graph.nodes:
|
||
register_node(
|
||
graph,
|
||
subgraph_stack,
|
||
MermaidNode(id=node_id, label=node_id, shape="rectangle"),
|
||
)
|
||
else:
|
||
track_in_subgraph(subgraph_stack, node_id)
|
||
remaining = text[len(m.group(0)) :]
|
||
|
||
if node_id is None:
|
||
return None
|
||
|
||
class_match = CLASS_SHORTHAND_REGEX.match(remaining)
|
||
if class_match:
|
||
graph.classAssignments[node_id] = class_match.group(1)
|
||
remaining = remaining[len(class_match.group(0)) :] # type: ignore[index]
|
||
|
||
return {"id": node_id, "remaining": remaining}
|
||
|
||
|
||
def register_node(
|
||
graph: MermaidGraph, subgraph_stack: list[MermaidSubgraph], node: MermaidNode
|
||
) -> None:
|
||
if node.id not in graph.nodes:
|
||
graph.nodes[node.id] = node
|
||
track_in_subgraph(subgraph_stack, node.id)
|
||
|
||
|
||
def track_in_subgraph(subgraph_stack: list[MermaidSubgraph], node_id: str) -> None:
|
||
if subgraph_stack:
|
||
current = subgraph_stack[-1]
|
||
if node_id not in current.nodeIds:
|
||
current.nodeIds.append(node_id)
|
||
|
||
|
||
# =============================================================================
|
||
# Parser: sequence
|
||
# =============================================================================
|
||
|
||
|
||
def parse_sequence_diagram(lines: list[str]) -> SequenceDiagram:
|
||
diagram = SequenceDiagram(actors=[], messages=[], blocks=[], notes=[])
|
||
actor_ids: set[str] = set()
|
||
block_stack: list[dict[str, object]] = []
|
||
|
||
for line in lines[1:]:
|
||
actor_match = re.match(r"^(participant|actor)\s+(\S+?)(?:\s+as\s+(.+))?$", line)
|
||
if actor_match:
|
||
typ = actor_match.group(1)
|
||
aid = actor_match.group(2)
|
||
label = actor_match.group(3).strip() if actor_match.group(3) else aid
|
||
if aid not in actor_ids:
|
||
actor_ids.add(aid)
|
||
diagram.actors.append(Actor(id=aid, label=label, type=typ))
|
||
continue
|
||
|
||
note_match = re.match(
|
||
r"^Note\s+(left of|right of|over)\s+([^:]+):\s*(.+)$", line, re.I
|
||
)
|
||
if note_match:
|
||
pos_str = note_match.group(1).lower()
|
||
actors_str = note_match.group(2).strip()
|
||
text = note_match.group(3).strip()
|
||
note_actor_ids = [s.strip() for s in actors_str.split(",")]
|
||
for aid in note_actor_ids:
|
||
ensure_actor(diagram, actor_ids, aid)
|
||
position = "over"
|
||
if pos_str == "left of":
|
||
position = "left"
|
||
elif pos_str == "right of":
|
||
position = "right"
|
||
diagram.notes.append(
|
||
Note(
|
||
actorIds=note_actor_ids,
|
||
text=text,
|
||
position=position,
|
||
afterIndex=len(diagram.messages) - 1,
|
||
)
|
||
)
|
||
continue
|
||
|
||
block_match = re.match(r"^(loop|alt|opt|par|critical|break|rect)\s*(.*)$", line)
|
||
if block_match:
|
||
block_type = block_match.group(1)
|
||
label = (block_match.group(2) or "").strip()
|
||
block_stack.append(
|
||
{
|
||
"type": block_type,
|
||
"label": label,
|
||
"startIndex": len(diagram.messages),
|
||
"dividers": [],
|
||
}
|
||
)
|
||
continue
|
||
|
||
divider_match = re.match(r"^(else|and)\s*(.*)$", line)
|
||
if divider_match and block_stack:
|
||
label = (divider_match.group(2) or "").strip()
|
||
block_stack[-1]["dividers"].append(
|
||
BlockDivider(index=len(diagram.messages), label=label)
|
||
)
|
||
continue
|
||
|
||
if line == "end" and block_stack:
|
||
completed = block_stack.pop()
|
||
diagram.blocks.append(
|
||
Block(
|
||
type=completed["type"],
|
||
label=completed["label"],
|
||
startIndex=completed["startIndex"],
|
||
endIndex=max(len(diagram.messages) - 1, completed["startIndex"]),
|
||
dividers=completed["dividers"],
|
||
)
|
||
)
|
||
continue
|
||
|
||
msg_match = re.match(
|
||
r"^(\S+?)\s*(--?>?>|--?[)x]|--?>>|--?>)\s*([+-]?)(\S+?)\s*:\s*(.+)$", line
|
||
)
|
||
if msg_match:
|
||
frm = msg_match.group(1)
|
||
arrow = msg_match.group(2)
|
||
activation_mark = msg_match.group(3)
|
||
to = msg_match.group(4)
|
||
label = msg_match.group(5).strip()
|
||
|
||
ensure_actor(diagram, actor_ids, frm)
|
||
ensure_actor(diagram, actor_ids, to)
|
||
|
||
line_style = "dashed" if arrow.startswith("--") else "solid"
|
||
arrow_head = "filled" if (">>" in arrow or "x" in arrow) else "open"
|
||
|
||
msg = Message(
|
||
from_id=frm,
|
||
to_id=to,
|
||
label=label,
|
||
lineStyle=line_style,
|
||
arrowHead=arrow_head,
|
||
)
|
||
if activation_mark == "+":
|
||
msg.activate = True
|
||
if activation_mark == "-":
|
||
msg.deactivate = True
|
||
diagram.messages.append(msg)
|
||
continue
|
||
|
||
simple_msg = re.match(
|
||
r"^(\S+?)\s*(->>|-->>|-\)|--\)|-x|--x|->|-->)\s*([+-]?)(\S+?)\s*:\s*(.+)$",
|
||
line,
|
||
)
|
||
if simple_msg:
|
||
frm = simple_msg.group(1)
|
||
arrow = simple_msg.group(2)
|
||
activation_mark = simple_msg.group(3)
|
||
to = simple_msg.group(4)
|
||
label = simple_msg.group(5).strip()
|
||
|
||
ensure_actor(diagram, actor_ids, frm)
|
||
ensure_actor(diagram, actor_ids, to)
|
||
|
||
line_style = "dashed" if arrow.startswith("--") else "solid"
|
||
arrow_head = "filled" if (">>" in arrow or "x" in arrow) else "open"
|
||
msg = Message(
|
||
from_id=frm,
|
||
to_id=to,
|
||
label=label,
|
||
lineStyle=line_style,
|
||
arrowHead=arrow_head,
|
||
)
|
||
if activation_mark == "+":
|
||
msg.activate = True
|
||
if activation_mark == "-":
|
||
msg.deactivate = True
|
||
diagram.messages.append(msg)
|
||
continue
|
||
|
||
return diagram
|
||
|
||
|
||
def ensure_actor(diagram: SequenceDiagram, actor_ids: set[str], actor_id: str) -> None:
|
||
if actor_id not in actor_ids:
|
||
actor_ids.add(actor_id)
|
||
diagram.actors.append(Actor(id=actor_id, label=actor_id, type="participant"))
|
||
|
||
|
||
# =============================================================================
|
||
# Parser: class diagram
|
||
# =============================================================================
|
||
|
||
|
||
def parse_class_diagram(lines: list[str]) -> ClassDiagram:
|
||
diagram = ClassDiagram(classes=[], relationships=[], namespaces=[])
|
||
class_map: dict[str, ClassNode] = {}
|
||
current_namespace: ClassNamespace | None = None
|
||
current_class: ClassNode | None = None
|
||
brace_depth = 0
|
||
|
||
for line in lines[1:]:
|
||
if current_class and brace_depth > 0:
|
||
if line == "}":
|
||
brace_depth -= 1
|
||
if brace_depth == 0:
|
||
current_class = None
|
||
continue
|
||
|
||
annot_match = re.match(r"^<<(\w+)>>$", line)
|
||
if annot_match:
|
||
current_class.annotation = annot_match.group(1)
|
||
continue
|
||
|
||
member = parse_class_member(line)
|
||
if member:
|
||
if member["isMethod"]:
|
||
current_class.methods.append(member["member"])
|
||
else:
|
||
current_class.attributes.append(member["member"])
|
||
continue
|
||
|
||
ns_match = re.match(r"^namespace\s+(\S+)\s*\{$", line)
|
||
if ns_match:
|
||
current_namespace = ClassNamespace(name=ns_match.group(1), classIds=[])
|
||
continue
|
||
|
||
if line == "}" and current_namespace:
|
||
diagram.namespaces.append(current_namespace)
|
||
current_namespace = None
|
||
continue
|
||
|
||
class_block = re.match(r"^class\s+(\S+?)(?:\s*~(\w+)~)?\s*\{$", line)
|
||
if class_block:
|
||
cid = class_block.group(1)
|
||
generic = class_block.group(2)
|
||
cls = ensure_class(class_map, cid)
|
||
if generic:
|
||
cls.label = f"{cid}<{generic}>"
|
||
current_class = cls
|
||
brace_depth = 1
|
||
if current_namespace:
|
||
current_namespace.classIds.append(cid)
|
||
continue
|
||
|
||
class_only = re.match(r"^class\s+(\S+?)(?:\s*~(\w+)~)?\s*$", line)
|
||
if class_only:
|
||
cid = class_only.group(1)
|
||
generic = class_only.group(2)
|
||
cls = ensure_class(class_map, cid)
|
||
if generic:
|
||
cls.label = f"{cid}<{generic}>"
|
||
if current_namespace:
|
||
current_namespace.classIds.append(cid)
|
||
continue
|
||
|
||
inline_annot = re.match(r"^class\s+(\S+?)\s*\{\s*<<(\w+)>>\s*\}$", line)
|
||
if inline_annot:
|
||
cls = ensure_class(class_map, inline_annot.group(1))
|
||
cls.annotation = inline_annot.group(2)
|
||
continue
|
||
|
||
inline_attr = re.match(r"^(\S+?)\s*:\s*(.+)$", line)
|
||
if inline_attr:
|
||
rest = inline_attr.group(2)
|
||
if not re.search(r"<\|--|--|\*--|o--|-->|\.\.>|\.\.\|>", rest):
|
||
cls = ensure_class(class_map, inline_attr.group(1))
|
||
member = parse_class_member(rest)
|
||
if member:
|
||
if member["isMethod"]:
|
||
cls.methods.append(member["member"])
|
||
else:
|
||
cls.attributes.append(member["member"])
|
||
continue
|
||
|
||
rel = parse_class_relationship(line)
|
||
if rel:
|
||
ensure_class(class_map, rel.from_id)
|
||
ensure_class(class_map, rel.to_id)
|
||
diagram.relationships.append(rel)
|
||
continue
|
||
|
||
diagram.classes = list(class_map.values())
|
||
return diagram
|
||
|
||
|
||
def ensure_class(class_map: dict[str, ClassNode], cid: str) -> ClassNode:
|
||
if cid not in class_map:
|
||
class_map[cid] = ClassNode(id=cid, label=cid, attributes=[], methods=[])
|
||
return class_map[cid]
|
||
|
||
|
||
def parse_class_member(line: str) -> dict[str, object] | None:
|
||
trimmed = line.strip().rstrip(";")
|
||
if not trimmed:
|
||
return None
|
||
|
||
visibility = ""
|
||
rest = trimmed
|
||
if re.match(r"^[+\-#~]", rest):
|
||
visibility = rest[0]
|
||
rest = rest[1:].strip()
|
||
|
||
method_match = re.match(r"^(.+?)\(([^)]*)\)(?:\s*(.+))?$", rest)
|
||
if method_match:
|
||
name = method_match.group(1).strip()
|
||
typ = (method_match.group(3) or "").strip() or None
|
||
is_static = name.endswith("$") or "$" in rest
|
||
is_abstract = name.endswith("*") or "*" in rest
|
||
member = ClassMember(
|
||
visibility=visibility,
|
||
name=name.replace("$", "").replace("*", ""),
|
||
type=typ,
|
||
isStatic=is_static,
|
||
isAbstract=is_abstract,
|
||
)
|
||
return {"member": member, "isMethod": True}
|
||
|
||
parts = rest.split()
|
||
if len(parts) >= 2:
|
||
name = parts[0]
|
||
typ = " ".join(parts[1:])
|
||
else:
|
||
name = parts[0] if parts else rest
|
||
typ = None
|
||
|
||
is_static = name.endswith("$")
|
||
is_abstract = name.endswith("*")
|
||
member = ClassMember(
|
||
visibility=visibility,
|
||
name=name.replace("$", "").replace("*", "").rstrip(":"),
|
||
type=typ,
|
||
isStatic=is_static,
|
||
isAbstract=is_abstract,
|
||
)
|
||
return {"member": member, "isMethod": False}
|
||
|
||
|
||
def parse_class_relationship(line: str) -> ClassRelationship | None:
|
||
match = re.match(
|
||
r'^(\S+?)\s+(?:"([^"]*?)"\s+)?(<\|--|<\|\.\.|\*--|o--|-->|--\*|--o|--|>\s*|\.\.>|\.\.\|>|--)\s+(?:"([^"]*?)"\s+)?(\S+?)(?:\s*:\s*(.+))?$',
|
||
line,
|
||
)
|
||
if not match:
|
||
return None
|
||
|
||
from_id = match.group(1)
|
||
from_card = match.group(2) or None
|
||
arrow = match.group(3).strip()
|
||
to_card = match.group(4) or None
|
||
to_id = match.group(5)
|
||
label = (match.group(6) or "").strip() or None
|
||
|
||
parsed = parse_class_arrow(arrow)
|
||
if not parsed:
|
||
return None
|
||
|
||
return ClassRelationship(
|
||
from_id=from_id,
|
||
to_id=to_id,
|
||
type=parsed["type"],
|
||
markerAt=parsed["markerAt"],
|
||
label=label,
|
||
fromCardinality=from_card,
|
||
toCardinality=to_card,
|
||
)
|
||
|
||
|
||
def parse_class_arrow(arrow: str) -> dict[str, str] | None:
|
||
if arrow == "<|--":
|
||
return {"type": "inheritance", "markerAt": "from"}
|
||
if arrow == "<|..":
|
||
return {"type": "realization", "markerAt": "from"}
|
||
if arrow == "*--":
|
||
return {"type": "composition", "markerAt": "from"}
|
||
if arrow == "--*":
|
||
return {"type": "composition", "markerAt": "to"}
|
||
if arrow == "o--":
|
||
return {"type": "aggregation", "markerAt": "from"}
|
||
if arrow == "--o":
|
||
return {"type": "aggregation", "markerAt": "to"}
|
||
if arrow == "-->":
|
||
return {"type": "association", "markerAt": "to"}
|
||
if arrow == "..>":
|
||
return {"type": "dependency", "markerAt": "to"}
|
||
if arrow == "..|>":
|
||
return {"type": "realization", "markerAt": "to"}
|
||
if arrow == "--":
|
||
return {"type": "association", "markerAt": "to"}
|
||
return None
|
||
|
||
|
||
# =============================================================================
|
||
# Parser: ER diagram
|
||
# =============================================================================
|
||
|
||
|
||
def parse_er_diagram(lines: list[str]) -> ErDiagram:
|
||
diagram = ErDiagram(entities=[], relationships=[])
|
||
entity_map: dict[str, ErEntity] = {}
|
||
current_entity: ErEntity | None = None
|
||
|
||
for line in lines[1:]:
|
||
if current_entity:
|
||
if line == "}":
|
||
current_entity = None
|
||
continue
|
||
attr = parse_er_attribute(line)
|
||
if attr:
|
||
current_entity.attributes.append(attr)
|
||
continue
|
||
|
||
entity_block = re.match(r"^(\S+)\s*\{$", line)
|
||
if entity_block:
|
||
eid = entity_block.group(1)
|
||
entity = ensure_entity(entity_map, eid)
|
||
current_entity = entity
|
||
continue
|
||
|
||
rel = parse_er_relationship_line(line)
|
||
if rel:
|
||
ensure_entity(entity_map, rel.entity1)
|
||
ensure_entity(entity_map, rel.entity2)
|
||
diagram.relationships.append(rel)
|
||
continue
|
||
|
||
diagram.entities = list(entity_map.values())
|
||
return diagram
|
||
|
||
|
||
def ensure_entity(entity_map: dict[str, ErEntity], eid: str) -> ErEntity:
|
||
if eid not in entity_map:
|
||
entity_map[eid] = ErEntity(id=eid, label=eid, attributes=[])
|
||
return entity_map[eid]
|
||
|
||
|
||
def parse_er_attribute(line: str) -> ErAttribute | None:
|
||
m = re.match(r"^(\S+)\s+(\S+)(?:\s+(.+))?$", line)
|
||
if not m:
|
||
return None
|
||
typ = m.group(1)
|
||
name = m.group(2)
|
||
rest = (m.group(3) or "").strip()
|
||
|
||
keys: list[str] = []
|
||
comment: str | None = None
|
||
comment_match = re.search(r'"([^"]*)"', rest)
|
||
if comment_match:
|
||
comment = comment_match.group(1)
|
||
|
||
rest_wo_comment = re.sub(r'"[^"]*"', "", rest).strip()
|
||
for part in rest_wo_comment.split():
|
||
upper = part.upper()
|
||
if upper in ("PK", "FK", "UK"):
|
||
keys.append(upper)
|
||
|
||
return ErAttribute(type=typ, name=name, keys=keys, comment=comment)
|
||
|
||
|
||
def parse_er_relationship_line(line: str) -> ErRelationship | None:
|
||
m = re.match(r"^(\S+)\s+([|o}{]+(?:--|\.\.)[|o}{]+)\s+(\S+)\s*:\s*(.+)$", line)
|
||
if not m:
|
||
return None
|
||
entity1 = m.group(1)
|
||
card_str = m.group(2)
|
||
entity2 = m.group(3)
|
||
label = m.group(4).strip()
|
||
|
||
line_match = re.match(r"^([|o}{]+)(--|\.\.?)([|o}{]+)$", card_str)
|
||
if not line_match:
|
||
return None
|
||
left_str = line_match.group(1)
|
||
line_style = line_match.group(2)
|
||
right_str = line_match.group(3)
|
||
|
||
card1 = parse_cardinality(left_str)
|
||
card2 = parse_cardinality(right_str)
|
||
identifying = line_style == "--"
|
||
|
||
if not card1 or not card2:
|
||
return None
|
||
|
||
return ErRelationship(
|
||
entity1=entity1,
|
||
entity2=entity2,
|
||
cardinality1=card1,
|
||
cardinality2=card2,
|
||
label=label,
|
||
identifying=identifying,
|
||
)
|
||
|
||
|
||
def parse_cardinality(s: str) -> str | None:
|
||
sorted_str = "".join(sorted(s))
|
||
if sorted_str == "||":
|
||
return "one"
|
||
if sorted_str == "o|":
|
||
return "zero-one"
|
||
if sorted_str in ("|}", "{|"):
|
||
return "many"
|
||
if sorted_str in ("{o", "o{"):
|
||
return "zero-many"
|
||
return None
|
||
|
||
|
||
# =============================================================================
|
||
# Converter: MermaidGraph -> AsciiGraph
|
||
# =============================================================================
|
||
|
||
|
||
def convert_to_ascii_graph(parsed: MermaidGraph, config: AsciiConfig) -> AsciiGraph:
|
||
node_map: dict[str, AsciiNode] = {}
|
||
index = 0
|
||
|
||
for node_id, m_node in parsed.nodes.items():
|
||
ascii_node = AsciiNode(
|
||
name=node_id,
|
||
displayLabel=m_node.label,
|
||
index=index,
|
||
gridCoord=None,
|
||
drawingCoord=None,
|
||
drawing=None,
|
||
drawn=False,
|
||
styleClassName="",
|
||
styleClass=EMPTY_STYLE,
|
||
)
|
||
node_map[node_id] = ascii_node
|
||
index += 1
|
||
|
||
nodes = list(node_map.values())
|
||
|
||
edges: list[AsciiEdge] = []
|
||
for m_edge in parsed.edges:
|
||
from_node = node_map.get(m_edge.source)
|
||
to_node = node_map.get(m_edge.target)
|
||
if not from_node or not to_node:
|
||
continue
|
||
edges.append(
|
||
AsciiEdge(
|
||
from_node=from_node,
|
||
to_node=to_node,
|
||
text=m_edge.label or "",
|
||
path=[],
|
||
labelLine=[],
|
||
startDir=Direction(0, 0),
|
||
endDir=Direction(0, 0),
|
||
)
|
||
)
|
||
|
||
subgraphs: list[AsciiSubgraph] = []
|
||
for msg in parsed.subgraphs:
|
||
convert_subgraph(msg, None, node_map, subgraphs)
|
||
|
||
deduplicate_subgraph_nodes(parsed.subgraphs, subgraphs, node_map)
|
||
|
||
for node_id, class_name in parsed.classAssignments.items():
|
||
node = node_map.get(node_id)
|
||
class_def = parsed.classDefs.get(class_name)
|
||
if node and class_def:
|
||
node.styleClassName = class_name
|
||
node.styleClass = AsciiStyleClass(name=class_name, styles=class_def)
|
||
|
||
return AsciiGraph(
|
||
nodes=nodes,
|
||
edges=edges,
|
||
canvas=mk_canvas(0, 0),
|
||
grid={},
|
||
columnWidth={},
|
||
rowHeight={},
|
||
subgraphs=subgraphs,
|
||
config=config,
|
||
offsetX=0,
|
||
offsetY=0,
|
||
)
|
||
|
||
|
||
def convert_subgraph(
|
||
m_sg: MermaidSubgraph,
|
||
parent: AsciiSubgraph | None,
|
||
node_map: dict[str, AsciiNode],
|
||
all_sgs: list[AsciiSubgraph],
|
||
) -> AsciiSubgraph:
|
||
sg = AsciiSubgraph(
|
||
name=m_sg.label,
|
||
nodes=[],
|
||
parent=parent,
|
||
children=[],
|
||
direction=m_sg.direction,
|
||
minX=0,
|
||
minY=0,
|
||
maxX=0,
|
||
maxY=0,
|
||
)
|
||
for node_id in m_sg.nodeIds:
|
||
node = node_map.get(node_id)
|
||
if node:
|
||
sg.nodes.append(node)
|
||
|
||
all_sgs.append(sg)
|
||
|
||
for child_m in m_sg.children:
|
||
child = convert_subgraph(child_m, sg, node_map, all_sgs)
|
||
sg.children.append(child)
|
||
for child_node in child.nodes:
|
||
if child_node not in sg.nodes:
|
||
sg.nodes.append(child_node)
|
||
|
||
return sg
|
||
|
||
|
||
def deduplicate_subgraph_nodes(
|
||
mermaid_sgs: list[MermaidSubgraph],
|
||
ascii_sgs: list[AsciiSubgraph],
|
||
node_map: dict[str, AsciiNode],
|
||
) -> None:
|
||
sg_map: dict[int, AsciiSubgraph] = {}
|
||
build_sg_map(mermaid_sgs, ascii_sgs, sg_map)
|
||
|
||
node_owner: dict[str, AsciiSubgraph] = {}
|
||
|
||
def claim_nodes(m_sg: MermaidSubgraph) -> None:
|
||
ascii_sg = sg_map.get(id(m_sg))
|
||
if not ascii_sg:
|
||
return
|
||
for child in m_sg.children:
|
||
claim_nodes(child)
|
||
for node_id in m_sg.nodeIds:
|
||
if node_id not in node_owner:
|
||
node_owner[node_id] = ascii_sg
|
||
|
||
for m_sg in mermaid_sgs:
|
||
claim_nodes(m_sg)
|
||
|
||
for ascii_sg in ascii_sgs:
|
||
filtered: list[AsciiNode] = []
|
||
for node in ascii_sg.nodes:
|
||
node_id = None
|
||
for nid, n in node_map.items():
|
||
if n is node:
|
||
node_id = nid
|
||
break
|
||
if not node_id:
|
||
continue
|
||
owner = node_owner.get(node_id)
|
||
if not owner:
|
||
filtered.append(node)
|
||
continue
|
||
if is_ancestor_or_self(ascii_sg, owner):
|
||
filtered.append(node)
|
||
ascii_sg.nodes = filtered
|
||
|
||
|
||
def is_ancestor_or_self(candidate: AsciiSubgraph, target: AsciiSubgraph) -> bool:
|
||
current: AsciiSubgraph | None = target
|
||
while current is not None:
|
||
if current is candidate:
|
||
return True
|
||
current = current.parent
|
||
return False
|
||
|
||
|
||
def build_sg_map(
|
||
m_sgs: list[MermaidSubgraph],
|
||
a_sgs: list[AsciiSubgraph],
|
||
result: dict[int, AsciiSubgraph],
|
||
) -> None:
|
||
flat_mermaid: list[MermaidSubgraph] = []
|
||
|
||
def flatten(sgs: list[MermaidSubgraph]) -> None:
|
||
for sg in sgs:
|
||
flat_mermaid.append(sg)
|
||
flatten(sg.children)
|
||
|
||
flatten(m_sgs)
|
||
|
||
for i in range(min(len(flat_mermaid), len(a_sgs))):
|
||
result[id(flat_mermaid[i])] = a_sgs[i]
|
||
|
||
|
||
# =============================================================================
|
||
# Pathfinder (A*)
|
||
# =============================================================================
|
||
|
||
|
||
@dataclass(order=True)
|
||
class PQItem:
|
||
priority: int
|
||
coord: GridCoord = field(compare=False)
|
||
|
||
|
||
class MinHeap:
|
||
def __init__(self) -> None:
|
||
self.items: list[PQItem] = []
|
||
|
||
def __len__(self) -> int:
|
||
return len(self.items)
|
||
|
||
def push(self, item: PQItem) -> None:
|
||
self.items.append(item)
|
||
self._bubble_up(len(self.items) - 1)
|
||
|
||
def pop(self) -> PQItem | None:
|
||
if not self.items:
|
||
return None
|
||
top = self.items[0]
|
||
last = self.items.pop()
|
||
if self.items:
|
||
self.items[0] = last
|
||
self._sink_down(0)
|
||
return top
|
||
|
||
def _bubble_up(self, i: int) -> None:
|
||
while i > 0:
|
||
parent = (i - 1) >> 1
|
||
if self.items[i].priority < self.items[parent].priority:
|
||
self.items[i], self.items[parent] = self.items[parent], self.items[i]
|
||
i = parent
|
||
else:
|
||
break
|
||
|
||
def _sink_down(self, i: int) -> None:
|
||
n = len(self.items)
|
||
while True:
|
||
smallest = i
|
||
left = 2 * i + 1
|
||
right = 2 * i + 2
|
||
if left < n and self.items[left].priority < self.items[smallest].priority:
|
||
smallest = left
|
||
if right < n and self.items[right].priority < self.items[smallest].priority:
|
||
smallest = right
|
||
if smallest != i:
|
||
self.items[i], self.items[smallest] = (
|
||
self.items[smallest],
|
||
self.items[i],
|
||
)
|
||
i = smallest
|
||
else:
|
||
break
|
||
|
||
|
||
def heuristic(a: GridCoord, b: GridCoord) -> int:
|
||
abs_x = abs(a.x - b.x)
|
||
abs_y = abs(a.y - b.y)
|
||
if abs_x == 0 or abs_y == 0:
|
||
return abs_x + abs_y
|
||
return abs_x + abs_y + 1
|
||
|
||
|
||
MOVE_DIRS = [GridCoord(1, 0), GridCoord(-1, 0), GridCoord(0, 1), GridCoord(0, -1)]
|
||
|
||
|
||
def is_free_in_grid(grid: dict[str, AsciiNode], c: GridCoord) -> bool:
|
||
if c.x < 0 or c.y < 0:
|
||
return False
|
||
return grid_key(c) not in grid
|
||
|
||
|
||
def get_path(
|
||
grid: dict[str, AsciiNode], frm: GridCoord, to: GridCoord
|
||
) -> list[GridCoord] | None:
|
||
# Bound A* search space so impossible routes terminate quickly.
|
||
dist = abs(frm.x - to.x) + abs(frm.y - to.y)
|
||
margin = max(12, dist * 2)
|
||
min_x = max(0, min(frm.x, to.x) - margin)
|
||
max_x = max(frm.x, to.x) + margin
|
||
min_y = max(0, min(frm.y, to.y) - margin)
|
||
max_y = max(frm.y, to.y) + margin
|
||
max_visited = 30000
|
||
|
||
pq = MinHeap()
|
||
pq.push(PQItem(priority=0, coord=frm))
|
||
|
||
cost_so_far: dict[str, int] = {grid_key(frm): 0}
|
||
came_from: dict[str, GridCoord | None] = {grid_key(frm): None}
|
||
visited = 0
|
||
|
||
while len(pq) > 0:
|
||
visited += 1
|
||
if visited > max_visited:
|
||
return None
|
||
|
||
current = pq.pop().coord # type: ignore[union-attr]
|
||
if grid_coord_equals(current, to):
|
||
path: list[GridCoord] = []
|
||
c: GridCoord | None = current
|
||
while c is not None:
|
||
path.insert(0, c)
|
||
c = came_from.get(grid_key(c))
|
||
return path
|
||
|
||
current_cost = cost_so_far[grid_key(current)]
|
||
|
||
for d in MOVE_DIRS:
|
||
nxt = GridCoord(current.x + d.x, current.y + d.y)
|
||
if nxt.x < min_x or nxt.x > max_x or nxt.y < min_y or nxt.y > max_y:
|
||
continue
|
||
if (not is_free_in_grid(grid, nxt)) and (not grid_coord_equals(nxt, to)):
|
||
continue
|
||
new_cost = current_cost + 1
|
||
key = grid_key(nxt)
|
||
existing = cost_so_far.get(key)
|
||
if existing is None or new_cost < existing:
|
||
cost_so_far[key] = new_cost
|
||
priority = new_cost + heuristic(nxt, to)
|
||
pq.push(PQItem(priority=priority, coord=nxt))
|
||
came_from[key] = current
|
||
|
||
return None
|
||
|
||
|
||
def merge_path(path: list[GridCoord]) -> list[GridCoord]:
|
||
if len(path) <= 2:
|
||
return path
|
||
to_remove: set[int] = set()
|
||
step0 = path[0]
|
||
step1 = path[1]
|
||
for idx in range(2, len(path)):
|
||
step2 = path[idx]
|
||
prev_dx = step1.x - step0.x
|
||
prev_dy = step1.y - step0.y
|
||
dx = step2.x - step1.x
|
||
dy = step2.y - step1.y
|
||
if prev_dx == dx and prev_dy == dy:
|
||
to_remove.add(idx - 1)
|
||
step0 = step1
|
||
step1 = step2
|
||
return [p for i, p in enumerate(path) if i not in to_remove]
|
||
|
||
|
||
# =============================================================================
|
||
# Edge routing
|
||
# =============================================================================
|
||
|
||
|
||
def dir_equals(a: Direction, b: Direction) -> bool:
|
||
return a.x == b.x and a.y == b.y
|
||
|
||
|
||
def get_opposite(d: Direction) -> Direction:
|
||
if dir_equals(d, Up):
|
||
return Down
|
||
if dir_equals(d, Down):
|
||
return Up
|
||
if dir_equals(d, Left):
|
||
return Right
|
||
if dir_equals(d, Right):
|
||
return Left
|
||
if dir_equals(d, UpperRight):
|
||
return LowerLeft
|
||
if dir_equals(d, UpperLeft):
|
||
return LowerRight
|
||
if dir_equals(d, LowerRight):
|
||
return UpperLeft
|
||
if dir_equals(d, LowerLeft):
|
||
return UpperRight
|
||
return Middle
|
||
|
||
|
||
def determine_direction(
|
||
frm: GridCoord | DrawingCoord, to: GridCoord | DrawingCoord
|
||
) -> Direction:
|
||
if frm.x == to.x:
|
||
return Down if frm.y < to.y else Up
|
||
if frm.y == to.y:
|
||
return Right if frm.x < to.x else Left
|
||
if frm.x < to.x:
|
||
return LowerRight if frm.y < to.y else UpperRight
|
||
return LowerLeft if frm.y < to.y else UpperLeft
|
||
|
||
|
||
def self_reference_direction(
|
||
graph_direction: str,
|
||
) -> tuple[Direction, Direction, Direction, Direction]:
|
||
if graph_direction == "LR":
|
||
return (Right, Down, Down, Right)
|
||
return (Down, Right, Right, Down)
|
||
|
||
|
||
def determine_start_and_end_dir(
|
||
edge: AsciiEdge, graph_direction: str
|
||
) -> tuple[Direction, Direction, Direction, Direction]:
|
||
if edge.from_node is edge.to_node:
|
||
return self_reference_direction(graph_direction)
|
||
|
||
d = determine_direction(edge.from_node.gridCoord, edge.to_node.gridCoord) # type: ignore[arg-type]
|
||
|
||
is_backwards = (
|
||
graph_direction == "LR"
|
||
and (
|
||
dir_equals(d, Left) or dir_equals(d, UpperLeft) or dir_equals(d, LowerLeft)
|
||
)
|
||
) or (
|
||
graph_direction == "TD"
|
||
and (dir_equals(d, Up) or dir_equals(d, UpperLeft) or dir_equals(d, UpperRight))
|
||
)
|
||
|
||
if dir_equals(d, LowerRight):
|
||
if graph_direction == "LR":
|
||
preferred_dir, preferred_opp = Down, Left
|
||
alt_dir, alt_opp = Right, Up
|
||
else:
|
||
preferred_dir, preferred_opp = Right, Up
|
||
alt_dir, alt_opp = Down, Left
|
||
elif dir_equals(d, UpperRight):
|
||
if graph_direction == "LR":
|
||
preferred_dir, preferred_opp = Up, Left
|
||
alt_dir, alt_opp = Right, Down
|
||
else:
|
||
preferred_dir, preferred_opp = Right, Down
|
||
alt_dir, alt_opp = Up, Left
|
||
elif dir_equals(d, LowerLeft):
|
||
if graph_direction == "LR":
|
||
preferred_dir, preferred_opp = Down, Down
|
||
alt_dir, alt_opp = Left, Up
|
||
else:
|
||
preferred_dir, preferred_opp = Left, Up
|
||
alt_dir, alt_opp = Down, Right
|
||
elif dir_equals(d, UpperLeft):
|
||
if graph_direction == "LR":
|
||
preferred_dir, preferred_opp = Down, Down
|
||
alt_dir, alt_opp = Left, Down
|
||
else:
|
||
preferred_dir, preferred_opp = Right, Right
|
||
alt_dir, alt_opp = Up, Right
|
||
elif is_backwards:
|
||
if graph_direction == "LR" and dir_equals(d, Left):
|
||
preferred_dir, preferred_opp = Down, Down
|
||
alt_dir, alt_opp = Left, Right
|
||
elif graph_direction == "TD" and dir_equals(d, Up):
|
||
preferred_dir, preferred_opp = Right, Right
|
||
alt_dir, alt_opp = Up, Down
|
||
else:
|
||
preferred_dir = d
|
||
preferred_opp = get_opposite(d)
|
||
alt_dir = d
|
||
alt_opp = get_opposite(d)
|
||
else:
|
||
preferred_dir = d
|
||
preferred_opp = get_opposite(d)
|
||
alt_dir = d
|
||
alt_opp = get_opposite(d)
|
||
|
||
return preferred_dir, preferred_opp, alt_dir, alt_opp
|
||
|
||
|
||
def determine_path(graph: AsciiGraph, edge: AsciiEdge) -> None:
|
||
pref_dir, pref_opp, alt_dir, alt_opp = determine_start_and_end_dir(
|
||
edge, graph.config.graphDirection
|
||
)
|
||
from_is_pseudo = (
|
||
edge.from_node.name.startswith(("_start", "_end"))
|
||
and edge.from_node.displayLabel == ""
|
||
)
|
||
to_is_pseudo = (
|
||
edge.to_node.name.startswith(("_start", "_end"))
|
||
and edge.to_node.displayLabel == ""
|
||
)
|
||
|
||
def unique_dirs(items: list[Direction]) -> list[Direction]:
|
||
out: list[Direction] = []
|
||
for d in items:
|
||
if not any(dir_equals(d, e) for e in out):
|
||
out.append(d)
|
||
return out
|
||
|
||
def fanout_start_dirs() -> list[Direction]:
|
||
outgoing = [
|
||
e
|
||
for e in graph.edges
|
||
if e.from_node is edge.from_node and e.to_node.gridCoord is not None
|
||
]
|
||
if from_is_pseudo:
|
||
return unique_dirs([pref_dir, alt_dir, Down, Right, Left, Up])
|
||
if len(outgoing) <= 1:
|
||
return unique_dirs([pref_dir, alt_dir, Down, Right, Left, Up])
|
||
|
||
if graph.config.graphDirection == "TD":
|
||
ordered = sorted(
|
||
outgoing, key=lambda e: (e.to_node.gridCoord.x, e.to_node.gridCoord.y)
|
||
)
|
||
idx = ordered.index(edge)
|
||
if len(ordered) == 2:
|
||
fanout = [Down, Right]
|
||
else:
|
||
fanout = [Down, Left, Right]
|
||
if idx >= len(fanout):
|
||
idx = len(fanout) - 1
|
||
primary = fanout[idx if len(ordered) > 2 else idx]
|
||
return unique_dirs([primary, pref_dir, alt_dir, Down, Left, Right, Up])
|
||
|
||
ordered = sorted(
|
||
outgoing, key=lambda e: (e.to_node.gridCoord.y, e.to_node.gridCoord.x)
|
||
)
|
||
idx = ordered.index(edge)
|
||
if len(ordered) == 2:
|
||
fanout = [Up, Down]
|
||
else:
|
||
fanout = [Up, Right, Down]
|
||
if idx >= len(fanout):
|
||
idx = len(fanout) - 1
|
||
primary = fanout[idx if len(ordered) > 2 else idx]
|
||
return unique_dirs([primary, pref_dir, alt_dir, Right, Up, Down, Left])
|
||
|
||
def fanin_end_dirs() -> list[Direction]:
|
||
incoming = [
|
||
e
|
||
for e in graph.edges
|
||
if e.to_node is edge.to_node and e.from_node.gridCoord is not None
|
||
]
|
||
if to_is_pseudo:
|
||
return unique_dirs([pref_opp, alt_opp, Up, Left, Right, Down])
|
||
if len(incoming) <= 1:
|
||
return unique_dirs([pref_opp, alt_opp, Up, Left, Right, Down])
|
||
|
||
if graph.config.graphDirection == "TD":
|
||
ordered = sorted(
|
||
incoming,
|
||
key=lambda e: (e.from_node.gridCoord.x, e.from_node.gridCoord.y),
|
||
)
|
||
idx = ordered.index(edge)
|
||
if len(ordered) == 2:
|
||
fanin = [Left, Right]
|
||
else:
|
||
fanin = [Left, Up, Right]
|
||
if idx >= len(fanin):
|
||
idx = len(fanin) - 1
|
||
primary = fanin[idx if len(ordered) > 2 else idx]
|
||
return unique_dirs([primary, pref_opp, alt_opp, Up, Left, Right, Down])
|
||
|
||
ordered = sorted(
|
||
incoming, key=lambda e: (e.from_node.gridCoord.y, e.from_node.gridCoord.x)
|
||
)
|
||
idx = ordered.index(edge)
|
||
if len(ordered) == 2:
|
||
fanin = [Up, Down]
|
||
else:
|
||
fanin = [Up, Left, Down]
|
||
if idx >= len(fanin):
|
||
idx = len(fanin) - 1
|
||
primary = fanin[idx if len(ordered) > 2 else idx]
|
||
return unique_dirs([primary, pref_opp, alt_opp, Left, Up, Down, Right])
|
||
|
||
def path_keys(path: list[GridCoord]) -> set[str]:
|
||
if len(path) <= 2:
|
||
return set()
|
||
return {grid_key(p) for p in path[1:-1]}
|
||
|
||
def overlap_penalty(candidate: list[GridCoord], sdir: Direction) -> int:
|
||
me = path_keys(candidate)
|
||
if not me:
|
||
return 0
|
||
penalty = 0
|
||
# Prefer fan-out direction consistent with target side.
|
||
if graph.config.graphDirection == "TD":
|
||
dx = edge.to_node.gridCoord.x - edge.from_node.gridCoord.x
|
||
if dx > 0 and dir_equals(sdir, Left) or dx < 0 and dir_equals(sdir, Right):
|
||
penalty += 50
|
||
elif dx == 0 and not dir_equals(sdir, Down):
|
||
penalty += 10
|
||
else:
|
||
dy = edge.to_node.gridCoord.y - edge.from_node.gridCoord.y
|
||
if dy > 0 and dir_equals(sdir, Up) or dy < 0 and dir_equals(sdir, Down):
|
||
penalty += 50
|
||
elif dy == 0 and not dir_equals(sdir, Right):
|
||
penalty += 10
|
||
|
||
for other in graph.edges:
|
||
if other is edge or not other.path:
|
||
continue
|
||
inter = me & path_keys(other.path)
|
||
if inter:
|
||
penalty += 100 * len(inter)
|
||
# Strongly discourage using the same start side for sibling fan-out.
|
||
if other.from_node is edge.from_node and dir_equals(other.startDir, sdir):
|
||
penalty += 20
|
||
|
||
# Avoid building a knot near the source by sharing early trunk cells.
|
||
if (
|
||
other.from_node is edge.from_node
|
||
and len(candidate) > 2
|
||
and len(other.path) > 2
|
||
):
|
||
minear = {grid_key(p) for p in candidate[:3]}
|
||
otherear = {grid_key(p) for p in other.path[:3]}
|
||
trunk = minear & otherear
|
||
if trunk:
|
||
penalty += 60 * len(trunk)
|
||
return penalty
|
||
|
||
def bend_count(path: list[GridCoord]) -> int:
|
||
if len(path) < 3:
|
||
return 0
|
||
bends = 0
|
||
prev = determine_direction(path[0], path[1])
|
||
for i in range(2, len(path)):
|
||
cur = determine_direction(path[i - 1], path[i])
|
||
if not dir_equals(cur, prev):
|
||
bends += 1
|
||
prev = cur
|
||
return bends
|
||
|
||
start_dirs = fanout_start_dirs()
|
||
end_dirs = fanin_end_dirs()
|
||
|
||
candidates: list[tuple[int, int, Direction, Direction, list[GridCoord]]] = []
|
||
fallback_candidates: list[
|
||
tuple[int, int, Direction, Direction, list[GridCoord]]
|
||
] = []
|
||
seen: set[tuple[str, str, str]] = set()
|
||
for sdir in start_dirs:
|
||
for edir in end_dirs:
|
||
frm = grid_coord_direction(edge.from_node.gridCoord, sdir)
|
||
to = grid_coord_direction(edge.to_node.gridCoord, edir)
|
||
p = get_path(graph.grid, frm, to)
|
||
if p is None:
|
||
continue
|
||
merged = merge_path(p)
|
||
key = (
|
||
f"{sdir.x}:{sdir.y}",
|
||
f"{edir.x}:{edir.y}",
|
||
",".join(grid_key(x) for x in merged),
|
||
)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
scored = (overlap_penalty(merged, sdir), len(merged), sdir, edir, merged)
|
||
if len(merged) >= 2:
|
||
candidates.append(scored)
|
||
else:
|
||
fallback_candidates.append(scored)
|
||
|
||
if not candidates:
|
||
if fallback_candidates:
|
||
fallback_candidates.sort(key=lambda x: (x[0], x[1]))
|
||
_, _, sdir, edir, best = fallback_candidates[0]
|
||
if len(best) == 1:
|
||
# Last resort: create a tiny dogleg to avoid a zero-length rendered edge.
|
||
p0 = best[0]
|
||
dirs = [sdir, edir, Down, Right, Left, Up]
|
||
for d in dirs:
|
||
n = GridCoord(p0.x + d.x, p0.y + d.y)
|
||
if n.x < 0 or n.y < 0:
|
||
continue
|
||
if is_free_in_grid(graph.grid, n):
|
||
best = [p0, n, p0]
|
||
break
|
||
edge.startDir = sdir
|
||
edge.endDir = edir
|
||
edge.path = best
|
||
return
|
||
edge.startDir = alt_dir
|
||
edge.endDir = alt_opp
|
||
edge.path = []
|
||
return
|
||
|
||
candidates.sort(key=lambda x: (x[0], bend_count(x[4]), x[1]))
|
||
_, _, sdir, edir, best = candidates[0]
|
||
edge.startDir = sdir
|
||
edge.endDir = edir
|
||
edge.path = best
|
||
|
||
|
||
def determine_label_line(graph: AsciiGraph, edge: AsciiEdge) -> None:
|
||
if not edge.text:
|
||
return
|
||
|
||
len_label = len(edge.text)
|
||
prev_step = edge.path[0]
|
||
largest_line = [prev_step, edge.path[1]]
|
||
largest_line_size = 0
|
||
|
||
for i in range(1, len(edge.path)):
|
||
step = edge.path[i]
|
||
line = [prev_step, step]
|
||
line_width = calculate_line_width(graph, line)
|
||
|
||
if line_width >= len_label:
|
||
largest_line = line
|
||
break
|
||
elif line_width > largest_line_size:
|
||
largest_line_size = line_width
|
||
largest_line = line
|
||
prev_step = step
|
||
|
||
min_x = min(largest_line[0].x, largest_line[1].x)
|
||
max_x = max(largest_line[0].x, largest_line[1].x)
|
||
middle_x = min_x + (max_x - min_x) // 2
|
||
|
||
current = graph.columnWidth.get(middle_x, 0)
|
||
graph.columnWidth[middle_x] = max(current, len_label + 2)
|
||
|
||
edge.labelLine = [largest_line[0], largest_line[1]]
|
||
|
||
|
||
def calculate_line_width(graph: AsciiGraph, line: list[GridCoord]) -> int:
|
||
total = 0
|
||
start_x = min(line[0].x, line[1].x)
|
||
end_x = max(line[0].x, line[1].x)
|
||
for x in range(start_x, end_x + 1):
|
||
total += graph.columnWidth.get(x, 0)
|
||
return total
|
||
|
||
|
||
# =============================================================================
|
||
# Grid layout
|
||
# =============================================================================
|
||
|
||
|
||
def grid_to_drawing_coord(
|
||
graph: AsciiGraph, c: GridCoord, d: Direction | None = None
|
||
) -> DrawingCoord:
|
||
target = GridCoord(c.x + d.x, c.y + d.y) if d else c
|
||
|
||
x = 0
|
||
for col in range(target.x):
|
||
x += graph.columnWidth.get(col, 0)
|
||
|
||
y = 0
|
||
for row in range(target.y):
|
||
y += graph.rowHeight.get(row, 0)
|
||
|
||
col_w = graph.columnWidth.get(target.x, 0)
|
||
row_h = graph.rowHeight.get(target.y, 0)
|
||
return DrawingCoord(
|
||
x=x + (col_w // 2) + graph.offsetX,
|
||
y=y + (row_h // 2) + graph.offsetY,
|
||
)
|
||
|
||
|
||
def line_to_drawing(graph: AsciiGraph, line: list[GridCoord]) -> list[DrawingCoord]:
|
||
return [grid_to_drawing_coord(graph, c) for c in line]
|
||
|
||
|
||
def reserve_spot_in_grid(
|
||
graph: AsciiGraph, node: AsciiNode, requested: GridCoord
|
||
) -> GridCoord:
|
||
is_pseudo = node.name.startswith(("_start", "_end")) and node.displayLabel == ""
|
||
footprint = (
|
||
[(0, 0)] if is_pseudo else [(dx, dy) for dx in range(3) for dy in range(3)]
|
||
)
|
||
|
||
def can_place(at: GridCoord) -> bool:
|
||
for dx, dy in footprint:
|
||
key = grid_key(GridCoord(at.x + dx, at.y + dy))
|
||
if key in graph.grid:
|
||
return False
|
||
return True
|
||
|
||
if not can_place(requested):
|
||
if graph.config.graphDirection == "LR":
|
||
return reserve_spot_in_grid(
|
||
graph, node, GridCoord(requested.x, requested.y + 4)
|
||
)
|
||
return reserve_spot_in_grid(
|
||
graph, node, GridCoord(requested.x + 4, requested.y)
|
||
)
|
||
|
||
for dx, dy in footprint:
|
||
reserved = GridCoord(requested.x + dx, requested.y + dy)
|
||
graph.grid[grid_key(reserved)] = node
|
||
|
||
node.gridCoord = requested
|
||
return requested
|
||
|
||
|
||
def has_incoming_edge_from_outside_subgraph(graph: AsciiGraph, node: AsciiNode) -> bool:
|
||
node_sg = get_node_subgraph(graph, node)
|
||
if not node_sg:
|
||
return False
|
||
|
||
has_external = False
|
||
for edge in graph.edges:
|
||
if edge.to_node is node:
|
||
source_sg = get_node_subgraph(graph, edge.from_node)
|
||
if source_sg is not node_sg:
|
||
has_external = True
|
||
break
|
||
if not has_external:
|
||
return False
|
||
|
||
for other in node_sg.nodes:
|
||
if other is node or not other.gridCoord:
|
||
continue
|
||
other_has_external = False
|
||
for edge in graph.edges:
|
||
if edge.to_node is other:
|
||
source_sg = get_node_subgraph(graph, edge.from_node)
|
||
if source_sg is not node_sg:
|
||
other_has_external = True
|
||
break
|
||
if other_has_external and other.gridCoord.y < node.gridCoord.y:
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
def set_column_width(graph: AsciiGraph, node: AsciiNode) -> None:
|
||
gc = node.gridCoord
|
||
padding = graph.config.boxBorderPadding
|
||
col_widths = [1, 2 * padding + len(node.displayLabel), 1]
|
||
row_heights = [1, 1 + 2 * padding, 1]
|
||
|
||
for idx, w in enumerate(col_widths):
|
||
x_coord = gc.x + idx
|
||
current = graph.columnWidth.get(x_coord, 0)
|
||
graph.columnWidth[x_coord] = max(current, w)
|
||
|
||
for idx, h in enumerate(row_heights):
|
||
y_coord = gc.y + idx
|
||
current = graph.rowHeight.get(y_coord, 0)
|
||
graph.rowHeight[y_coord] = max(current, h)
|
||
|
||
if gc.x > 0:
|
||
current = graph.columnWidth.get(gc.x - 1, 0)
|
||
graph.columnWidth[gc.x - 1] = max(current, graph.config.paddingX)
|
||
|
||
if gc.y > 0:
|
||
base_padding = graph.config.paddingY
|
||
if has_incoming_edge_from_outside_subgraph(graph, node):
|
||
base_padding += 4
|
||
current = graph.rowHeight.get(gc.y - 1, 0)
|
||
graph.rowHeight[gc.y - 1] = max(current, base_padding)
|
||
|
||
|
||
def increase_grid_size_for_path(graph: AsciiGraph, path: list[GridCoord]) -> None:
|
||
# Keep path-only spacer rows/cols present but compact.
|
||
path_pad_x = max(1, (graph.config.paddingX + 1) // 3)
|
||
path_pad_y = max(1, graph.config.paddingY // 3)
|
||
for c in path:
|
||
if c.x not in graph.columnWidth:
|
||
graph.columnWidth[c.x] = path_pad_x
|
||
if c.y not in graph.rowHeight:
|
||
graph.rowHeight[c.y] = path_pad_y
|
||
|
||
|
||
def is_node_in_any_subgraph(graph: AsciiGraph, node: AsciiNode) -> bool:
|
||
return any(node in sg.nodes for sg in graph.subgraphs)
|
||
|
||
|
||
def get_node_subgraph(graph: AsciiGraph, node: AsciiNode) -> AsciiSubgraph | None:
|
||
best: AsciiSubgraph | None = None
|
||
best_depth = -1
|
||
for sg in graph.subgraphs:
|
||
if node in sg.nodes:
|
||
depth = 0
|
||
cur = sg.parent
|
||
while cur is not None:
|
||
depth += 1
|
||
cur = cur.parent
|
||
if depth > best_depth:
|
||
best_depth = depth
|
||
best = sg
|
||
return best
|
||
|
||
|
||
def calculate_subgraph_bounding_box(graph: AsciiGraph, sg: AsciiSubgraph) -> None:
|
||
if not sg.nodes:
|
||
return
|
||
min_x = 1_000_000
|
||
min_y = 1_000_000
|
||
max_x = -1_000_000
|
||
max_y = -1_000_000
|
||
|
||
for child in sg.children:
|
||
calculate_subgraph_bounding_box(graph, child)
|
||
if child.nodes:
|
||
min_x = min(min_x, child.minX)
|
||
min_y = min(min_y, child.minY)
|
||
max_x = max(max_x, child.maxX)
|
||
max_y = max(max_y, child.maxY)
|
||
|
||
for node in sg.nodes:
|
||
if node.name.startswith(("_start", "_end")) and node.displayLabel == "":
|
||
continue
|
||
if not node.drawingCoord or not node.drawing:
|
||
continue
|
||
node_min_x = node.drawingCoord.x
|
||
node_min_y = node.drawingCoord.y
|
||
node_max_x = node_min_x + len(node.drawing) - 1
|
||
node_max_y = node_min_y + len(node.drawing[0]) - 1
|
||
min_x = min(min_x, node_min_x)
|
||
min_y = min(min_y, node_min_y)
|
||
max_x = max(max_x, node_max_x)
|
||
max_y = max(max_y, node_max_y)
|
||
|
||
# Composite/state groups looked too loose with larger margins.
|
||
subgraph_padding = 1
|
||
subgraph_label_space = 1
|
||
sg.minX = min_x - subgraph_padding
|
||
sg.minY = min_y - subgraph_padding - subgraph_label_space
|
||
sg.maxX = max_x + subgraph_padding
|
||
sg.maxY = max_y + subgraph_padding
|
||
|
||
|
||
def ensure_subgraph_spacing(graph: AsciiGraph) -> None:
|
||
min_spacing = 1
|
||
root_sgs = [sg for sg in graph.subgraphs if sg.parent is None and sg.nodes]
|
||
|
||
for i in range(len(root_sgs)):
|
||
for j in range(i + 1, len(root_sgs)):
|
||
sg1 = root_sgs[i]
|
||
sg2 = root_sgs[j]
|
||
|
||
if sg1.minX < sg2.maxX and sg1.maxX > sg2.minX:
|
||
if sg1.maxY >= sg2.minY - min_spacing and sg1.minY < sg2.minY:
|
||
sg2.minY = sg1.maxY + min_spacing + 1
|
||
elif sg2.maxY >= sg1.minY - min_spacing and sg2.minY < sg1.minY:
|
||
sg1.minY = sg2.maxY + min_spacing + 1
|
||
|
||
if sg1.minY < sg2.maxY and sg1.maxY > sg2.minY:
|
||
if sg1.maxX >= sg2.minX - min_spacing and sg1.minX < sg2.minX:
|
||
sg2.minX = sg1.maxX + min_spacing + 1
|
||
elif sg2.maxX >= sg1.minX - min_spacing and sg2.minX < sg1.minX:
|
||
sg1.minX = sg2.maxX + min_spacing + 1
|
||
|
||
|
||
def calculate_subgraph_bounding_boxes(graph: AsciiGraph) -> None:
|
||
for sg in graph.subgraphs:
|
||
calculate_subgraph_bounding_box(graph, sg)
|
||
ensure_subgraph_spacing(graph)
|
||
|
||
|
||
def offset_drawing_for_subgraphs(graph: AsciiGraph) -> None:
|
||
if not graph.subgraphs:
|
||
return
|
||
min_x = 0
|
||
min_y = 0
|
||
for sg in graph.subgraphs:
|
||
min_x = min(min_x, sg.minX)
|
||
min_y = min(min_y, sg.minY)
|
||
offset_x = -min_x
|
||
offset_y = -min_y
|
||
if offset_x == 0 and offset_y == 0:
|
||
return
|
||
graph.offsetX = offset_x
|
||
graph.offsetY = offset_y
|
||
for sg in graph.subgraphs:
|
||
sg.minX += offset_x
|
||
sg.minY += offset_y
|
||
sg.maxX += offset_x
|
||
sg.maxY += offset_y
|
||
for node in graph.nodes:
|
||
if node.drawingCoord:
|
||
node.drawingCoord = DrawingCoord(
|
||
node.drawingCoord.x + offset_x, node.drawingCoord.y + offset_y
|
||
)
|
||
|
||
|
||
def create_mapping(graph: AsciiGraph) -> None:
|
||
dirn = graph.config.graphDirection
|
||
highest_position_per_level = [0] * 100
|
||
# Reserve one leading lane so pseudo start/end markers can sit before roots.
|
||
highest_position_per_level[0] = 4
|
||
|
||
def is_pseudo_state_node(node: AsciiNode) -> bool:
|
||
return node.name.startswith(("_start", "_end")) and node.displayLabel == ""
|
||
|
||
def effective_dir_for_nodes(a: AsciiNode, b: AsciiNode) -> str:
|
||
a_sg = get_node_subgraph(graph, a)
|
||
b_sg = get_node_subgraph(graph, b)
|
||
if a_sg and b_sg and a_sg is b_sg and a_sg.direction:
|
||
return "LR" if a_sg.direction in ("LR", "RL") else "TD"
|
||
return dirn
|
||
|
||
nodes_found: set[str] = set()
|
||
root_nodes: list[AsciiNode] = []
|
||
|
||
for node in graph.nodes:
|
||
if is_pseudo_state_node(node):
|
||
# Pseudo state markers should not influence root discovery.
|
||
continue
|
||
if node.name not in nodes_found:
|
||
root_nodes.append(node)
|
||
nodes_found.add(node.name)
|
||
for child in get_children(graph, node):
|
||
if not is_pseudo_state_node(child):
|
||
nodes_found.add(child.name)
|
||
|
||
has_external_roots = False
|
||
has_subgraph_roots_with_edges = False
|
||
for node in root_nodes:
|
||
if is_node_in_any_subgraph(graph, node):
|
||
if get_children(graph, node):
|
||
has_subgraph_roots_with_edges = True
|
||
else:
|
||
has_external_roots = True
|
||
should_separate = has_external_roots and has_subgraph_roots_with_edges
|
||
|
||
if should_separate:
|
||
external_roots = [
|
||
n for n in root_nodes if not is_node_in_any_subgraph(graph, n)
|
||
]
|
||
subgraph_roots = [n for n in root_nodes if is_node_in_any_subgraph(graph, n)]
|
||
else:
|
||
external_roots = root_nodes
|
||
subgraph_roots = []
|
||
|
||
for node in external_roots:
|
||
requested = (
|
||
GridCoord(0, highest_position_per_level[0])
|
||
if dirn == "LR"
|
||
else GridCoord(highest_position_per_level[0], 4)
|
||
)
|
||
reserve_spot_in_grid(graph, graph.nodes[node.index], requested)
|
||
highest_position_per_level[0] += 4
|
||
|
||
if should_separate and subgraph_roots:
|
||
subgraph_level = 4 if dirn == "LR" else 10
|
||
for node in subgraph_roots:
|
||
requested = (
|
||
GridCoord(subgraph_level, highest_position_per_level[subgraph_level])
|
||
if dirn == "LR"
|
||
else GridCoord(
|
||
highest_position_per_level[subgraph_level], subgraph_level
|
||
)
|
||
)
|
||
reserve_spot_in_grid(graph, graph.nodes[node.index], requested)
|
||
highest_position_per_level[subgraph_level] += 4
|
||
|
||
# Expand parent -> child placement until no additional nodes can be placed.
|
||
for _ in range(len(graph.nodes) + 2):
|
||
changed = False
|
||
for node in graph.nodes:
|
||
if node.gridCoord is None:
|
||
continue
|
||
gc = node.gridCoord
|
||
for child in get_children(graph, node):
|
||
if child.gridCoord is not None:
|
||
continue
|
||
effective_dir = effective_dir_for_nodes(node, child)
|
||
child_level = gc.x + 4 if effective_dir == "LR" else gc.y + 4
|
||
base_position = gc.y if effective_dir == "LR" else gc.x
|
||
highest_position = max(
|
||
highest_position_per_level[child_level], base_position
|
||
)
|
||
requested = (
|
||
GridCoord(child_level, highest_position)
|
||
if effective_dir == "LR"
|
||
else GridCoord(highest_position, child_level)
|
||
)
|
||
reserve_spot_in_grid(graph, graph.nodes[child.index], requested)
|
||
highest_position_per_level[child_level] = highest_position + 4
|
||
changed = True
|
||
if not changed:
|
||
break
|
||
|
||
# Place pseudo state markers close to connected nodes instead of as global roots.
|
||
for _ in range(len(graph.nodes) + 2):
|
||
changed = False
|
||
for node in graph.nodes:
|
||
if node.gridCoord is not None or not is_pseudo_state_node(node):
|
||
continue
|
||
|
||
outgoing = [
|
||
e.to_node
|
||
for e in graph.edges
|
||
if e.from_node is node and e.to_node.gridCoord is not None
|
||
]
|
||
incoming = [
|
||
e.from_node
|
||
for e in graph.edges
|
||
if e.to_node is node and e.from_node.gridCoord is not None
|
||
]
|
||
anchor = outgoing[0] if outgoing else (incoming[0] if incoming else None)
|
||
if anchor is None:
|
||
continue
|
||
|
||
eff_dir = effective_dir_for_nodes(node, anchor)
|
||
if node.name.startswith("_start") and outgoing:
|
||
if eff_dir == "LR":
|
||
requested = GridCoord(
|
||
max(0, anchor.gridCoord.x - 2), anchor.gridCoord.y
|
||
)
|
||
else:
|
||
requested = GridCoord(
|
||
anchor.gridCoord.x, max(0, anchor.gridCoord.y - 2)
|
||
)
|
||
elif node.name.startswith("_end") and incoming:
|
||
if eff_dir == "LR":
|
||
requested = GridCoord(anchor.gridCoord.x + 2, anchor.gridCoord.y)
|
||
else:
|
||
requested = GridCoord(anchor.gridCoord.x, anchor.gridCoord.y + 2)
|
||
else:
|
||
if eff_dir == "LR":
|
||
requested = GridCoord(
|
||
max(0, anchor.gridCoord.x - 2), anchor.gridCoord.y
|
||
)
|
||
else:
|
||
requested = GridCoord(
|
||
anchor.gridCoord.x, max(0, anchor.gridCoord.y - 2)
|
||
)
|
||
|
||
reserve_spot_in_grid(graph, graph.nodes[node.index], requested)
|
||
changed = True
|
||
if not changed:
|
||
break
|
||
|
||
# Fallback for any remaining unplaced nodes (isolated/cyclic leftovers).
|
||
for node in graph.nodes:
|
||
if node.gridCoord is not None:
|
||
continue
|
||
requested = (
|
||
GridCoord(0, highest_position_per_level[0])
|
||
if dirn == "LR"
|
||
else GridCoord(highest_position_per_level[0], 4)
|
||
)
|
||
reserve_spot_in_grid(graph, graph.nodes[node.index], requested)
|
||
highest_position_per_level[0] += 4
|
||
|
||
for node in graph.nodes:
|
||
set_column_width(graph, node)
|
||
|
||
# Route edges, then reroute with global context to reduce crossings/overlaps.
|
||
for edge in graph.edges:
|
||
determine_path(graph, edge)
|
||
for _ in range(2):
|
||
for edge in graph.edges:
|
||
determine_path(graph, edge)
|
||
|
||
for edge in graph.edges:
|
||
increase_grid_size_for_path(graph, edge.path)
|
||
determine_label_line(graph, edge)
|
||
|
||
for node in graph.nodes:
|
||
node.drawingCoord = grid_to_drawing_coord(graph, node.gridCoord)
|
||
node.drawing = draw_box(node, graph)
|
||
|
||
set_canvas_size_to_grid(graph.canvas, graph.columnWidth, graph.rowHeight)
|
||
calculate_subgraph_bounding_boxes(graph)
|
||
offset_drawing_for_subgraphs(graph)
|
||
|
||
|
||
def get_edges_from_node(graph: AsciiGraph, node: AsciiNode) -> list[AsciiEdge]:
|
||
return [e for e in graph.edges if e.from_node.name == node.name]
|
||
|
||
|
||
def get_children(graph: AsciiGraph, node: AsciiNode) -> list[AsciiNode]:
|
||
return [e.to_node for e in get_edges_from_node(graph, node)]
|
||
|
||
|
||
# =============================================================================
|
||
# Draw
|
||
# =============================================================================
|
||
|
||
|
||
def draw_box(node: AsciiNode, graph: AsciiGraph) -> Canvas:
|
||
gc = node.gridCoord
|
||
use_ascii = graph.config.useAscii
|
||
|
||
w = 0
|
||
for i in range(2):
|
||
w += graph.columnWidth.get(gc.x + i, 0)
|
||
h = 0
|
||
for i in range(2):
|
||
h += graph.rowHeight.get(gc.y + i, 0)
|
||
|
||
frm = DrawingCoord(0, 0)
|
||
to = DrawingCoord(w, h)
|
||
box = mk_canvas(max(frm.x, to.x), max(frm.y, to.y))
|
||
|
||
is_pseudo = node.name.startswith(("_start", "_end")) and node.displayLabel == ""
|
||
|
||
if is_pseudo:
|
||
dot = mk_canvas(0, 0)
|
||
dot[0][0] = "*" if use_ascii else "●"
|
||
return dot
|
||
|
||
if not use_ascii:
|
||
for x in range(frm.x + 1, to.x):
|
||
box[x][frm.y] = "─"
|
||
box[x][to.y] = "─"
|
||
for y in range(frm.y + 1, to.y):
|
||
box[frm.x][y] = "│"
|
||
box[to.x][y] = "│"
|
||
box[frm.x][frm.y] = "┌"
|
||
box[to.x][frm.y] = "┐"
|
||
box[frm.x][to.y] = "└"
|
||
box[to.x][to.y] = "┘"
|
||
else:
|
||
for x in range(frm.x + 1, to.x):
|
||
box[x][frm.y] = "-"
|
||
box[x][to.y] = "-"
|
||
for y in range(frm.y + 1, to.y):
|
||
box[frm.x][y] = "|"
|
||
box[to.x][y] = "|"
|
||
box[frm.x][frm.y] = "+"
|
||
box[to.x][frm.y] = "+"
|
||
box[frm.x][to.y] = "+"
|
||
box[to.x][to.y] = "+"
|
||
|
||
label = node.displayLabel
|
||
text_y = frm.y + (h // 2)
|
||
text_x = frm.x + (w // 2) - ((len(label) + 1) // 2) + 1
|
||
for i, ch in enumerate(label):
|
||
box[text_x + i][text_y] = ch
|
||
|
||
return box
|
||
|
||
|
||
def draw_multi_box(
|
||
sections: list[list[str]], use_ascii: bool, padding: int = 1
|
||
) -> Canvas:
|
||
max_text = 0
|
||
for section in sections:
|
||
for line in section:
|
||
max_text = max(max_text, len(line))
|
||
inner_width = max_text + 2 * padding
|
||
box_width = inner_width + 2
|
||
|
||
total_lines = 0
|
||
for section in sections:
|
||
total_lines += max(len(section), 1)
|
||
num_dividers = len(sections) - 1
|
||
box_height = total_lines + num_dividers + 2
|
||
|
||
hline = "-" if use_ascii else "─"
|
||
vline = "|" if use_ascii else "│"
|
||
tl = "+" if use_ascii else "┌"
|
||
tr = "+" if use_ascii else "┐"
|
||
bl = "+" if use_ascii else "└"
|
||
br = "+" if use_ascii else "┘"
|
||
div_l = "+" if use_ascii else "├"
|
||
div_r = "+" if use_ascii else "┤"
|
||
|
||
canvas = mk_canvas(box_width - 1, box_height - 1)
|
||
|
||
canvas[0][0] = tl
|
||
for x in range(1, box_width - 1):
|
||
canvas[x][0] = hline
|
||
canvas[box_width - 1][0] = tr
|
||
|
||
canvas[0][box_height - 1] = bl
|
||
for x in range(1, box_width - 1):
|
||
canvas[x][box_height - 1] = hline
|
||
canvas[box_width - 1][box_height - 1] = br
|
||
|
||
for y in range(1, box_height - 1):
|
||
canvas[0][y] = vline
|
||
canvas[box_width - 1][y] = vline
|
||
|
||
row = 1
|
||
for s_idx, section in enumerate(sections):
|
||
lines = section if section else [""]
|
||
for line in lines:
|
||
start_x = 1 + padding
|
||
for i, ch in enumerate(line):
|
||
canvas[start_x + i][row] = ch
|
||
row += 1
|
||
if s_idx < len(sections) - 1:
|
||
canvas[0][row] = div_l
|
||
for x in range(1, box_width - 1):
|
||
canvas[x][row] = hline
|
||
canvas[box_width - 1][row] = div_r
|
||
row += 1
|
||
|
||
return canvas
|
||
|
||
|
||
def draw_line(
|
||
canvas: Canvas,
|
||
frm: DrawingCoord,
|
||
to: DrawingCoord,
|
||
offset_from: int,
|
||
offset_to: int,
|
||
use_ascii: bool,
|
||
) -> list[DrawingCoord]:
|
||
dirn = determine_direction(frm, to)
|
||
drawn: list[DrawingCoord] = []
|
||
|
||
h_char = "-" if use_ascii else "─"
|
||
v_char = "|" if use_ascii else "│"
|
||
bslash = "\\" if use_ascii else "╲"
|
||
fslash = "/" if use_ascii else "╱"
|
||
|
||
if dir_equals(dirn, Up):
|
||
for y in range(frm.y - offset_from, to.y - offset_to - 1, -1):
|
||
drawn.append(DrawingCoord(frm.x, y))
|
||
canvas[frm.x][y] = v_char
|
||
elif dir_equals(dirn, Down):
|
||
for y in range(frm.y + offset_from, to.y + offset_to + 1):
|
||
drawn.append(DrawingCoord(frm.x, y))
|
||
canvas[frm.x][y] = v_char
|
||
elif dir_equals(dirn, Left):
|
||
for x in range(frm.x - offset_from, to.x - offset_to - 1, -1):
|
||
drawn.append(DrawingCoord(x, frm.y))
|
||
canvas[x][frm.y] = h_char
|
||
elif dir_equals(dirn, Right):
|
||
for x in range(frm.x + offset_from, to.x + offset_to + 1):
|
||
drawn.append(DrawingCoord(x, frm.y))
|
||
canvas[x][frm.y] = h_char
|
||
elif dir_equals(dirn, UpperLeft):
|
||
x = frm.x
|
||
y = frm.y - offset_from
|
||
while x >= to.x - offset_to and y >= to.y - offset_to:
|
||
drawn.append(DrawingCoord(x, y))
|
||
canvas[x][y] = bslash
|
||
x -= 1
|
||
y -= 1
|
||
elif dir_equals(dirn, UpperRight):
|
||
x = frm.x
|
||
y = frm.y - offset_from
|
||
while x <= to.x + offset_to and y >= to.y - offset_to:
|
||
drawn.append(DrawingCoord(x, y))
|
||
canvas[x][y] = fslash
|
||
x += 1
|
||
y -= 1
|
||
elif dir_equals(dirn, LowerLeft):
|
||
x = frm.x
|
||
y = frm.y + offset_from
|
||
while x >= to.x - offset_to and y <= to.y + offset_to:
|
||
drawn.append(DrawingCoord(x, y))
|
||
canvas[x][y] = fslash
|
||
x -= 1
|
||
y += 1
|
||
elif dir_equals(dirn, LowerRight):
|
||
x = frm.x
|
||
y = frm.y + offset_from
|
||
while x <= to.x + offset_to and y <= to.y + offset_to:
|
||
drawn.append(DrawingCoord(x, y))
|
||
canvas[x][y] = bslash
|
||
x += 1
|
||
y += 1
|
||
|
||
return drawn
|
||
|
||
|
||
def draw_arrow(
|
||
graph: AsciiGraph, edge: AsciiEdge
|
||
) -> tuple[Canvas, Canvas, Canvas, Canvas, Canvas]:
|
||
if not edge.path:
|
||
empty = copy_canvas(graph.canvas)
|
||
return empty, empty, empty, empty, empty
|
||
|
||
label_canvas = draw_arrow_label(graph, edge)
|
||
path_canvas, lines_drawn, line_dirs = draw_path(graph, edge, edge.path)
|
||
if not lines_drawn or not line_dirs:
|
||
empty = copy_canvas(graph.canvas)
|
||
return path_canvas, empty, empty, empty, label_canvas
|
||
from_is_pseudo = (
|
||
edge.from_node.name.startswith(("_start", "_end"))
|
||
and edge.from_node.displayLabel == ""
|
||
)
|
||
from_out_degree = len(get_edges_from_node(graph, edge.from_node))
|
||
# Junction marks on dense fan-out nodes quickly turn into unreadable knots.
|
||
if from_is_pseudo or from_out_degree > 1:
|
||
box_start_canvas = copy_canvas(graph.canvas)
|
||
else:
|
||
box_start_canvas = draw_box_start(graph, edge.path, lines_drawn[0])
|
||
arrow_head_canvas = draw_arrow_head(graph, lines_drawn[-1], line_dirs[-1])
|
||
corners_canvas = draw_corners(graph, edge.path)
|
||
|
||
return (
|
||
path_canvas,
|
||
box_start_canvas,
|
||
arrow_head_canvas,
|
||
corners_canvas,
|
||
label_canvas,
|
||
)
|
||
|
||
|
||
def draw_path(
|
||
graph: AsciiGraph, edge: AsciiEdge, path: list[GridCoord]
|
||
) -> tuple[Canvas, list[list[DrawingCoord]], list[Direction]]:
|
||
canvas = copy_canvas(graph.canvas)
|
||
previous = path[0]
|
||
lines_drawn: list[list[DrawingCoord]] = []
|
||
line_dirs: list[Direction] = []
|
||
|
||
def border_coord(
|
||
node: AsciiNode, side: Direction, lane: DrawingCoord
|
||
) -> DrawingCoord:
|
||
left = node.drawingCoord.x
|
||
top = node.drawingCoord.y
|
||
width = len(node.drawing)
|
||
height = len(node.drawing[0])
|
||
cx = left + width // 2
|
||
cy = top + height // 2
|
||
if dir_equals(side, Left):
|
||
return DrawingCoord(left, lane.y)
|
||
if dir_equals(side, Right):
|
||
return DrawingCoord(left + width - 1, lane.y)
|
||
if dir_equals(side, Up):
|
||
return DrawingCoord(lane.x, top)
|
||
if dir_equals(side, Down):
|
||
return DrawingCoord(lane.x, top + height - 1)
|
||
return DrawingCoord(cx, cy)
|
||
|
||
for i in range(1, len(path)):
|
||
next_coord = path[i]
|
||
prev_dc = grid_to_drawing_coord(graph, previous)
|
||
next_dc = grid_to_drawing_coord(graph, next_coord)
|
||
if drawing_coord_equals(prev_dc, next_dc):
|
||
previous = next_coord
|
||
continue
|
||
dirn = determine_direction(previous, next_coord)
|
||
|
||
is_first = i == 1
|
||
is_last = i == len(path) - 1
|
||
|
||
if is_first:
|
||
node = graph.grid.get(grid_key(previous))
|
||
if node and node.drawingCoord and node.drawing:
|
||
prev_dc = border_coord(node, dirn, prev_dc)
|
||
if is_last:
|
||
node = graph.grid.get(grid_key(next_coord))
|
||
if node and node.drawingCoord and node.drawing:
|
||
next_dc = border_coord(node, get_opposite(dirn), next_dc)
|
||
|
||
offset_from = 0 if is_first else 1
|
||
offset_to = 0 if is_last else -1
|
||
segment = draw_line(
|
||
canvas, prev_dc, next_dc, offset_from, offset_to, graph.config.useAscii
|
||
)
|
||
if not segment:
|
||
segment.append(prev_dc)
|
||
lines_drawn.append(segment)
|
||
line_dirs.append(dirn)
|
||
previous = next_coord
|
||
|
||
return canvas, lines_drawn, line_dirs
|
||
|
||
|
||
def draw_box_start(
|
||
graph: AsciiGraph, path: list[GridCoord], first_line: list[DrawingCoord]
|
||
) -> Canvas:
|
||
canvas = copy_canvas(graph.canvas)
|
||
if graph.config.useAscii:
|
||
return canvas
|
||
|
||
frm = first_line[0]
|
||
dirn = determine_direction(path[0], path[1])
|
||
|
||
if dir_equals(dirn, Up):
|
||
canvas[frm.x][frm.y] = "┴"
|
||
elif dir_equals(dirn, Down):
|
||
canvas[frm.x][frm.y] = "┬"
|
||
elif dir_equals(dirn, Left):
|
||
canvas[frm.x][frm.y] = "┤"
|
||
elif dir_equals(dirn, Right):
|
||
canvas[frm.x][frm.y] = "├"
|
||
|
||
return canvas
|
||
|
||
|
||
def draw_arrow_head(
|
||
graph: AsciiGraph, last_line: list[DrawingCoord], fallback_dir: Direction
|
||
) -> Canvas:
|
||
canvas = copy_canvas(graph.canvas)
|
||
if not last_line:
|
||
return canvas
|
||
|
||
frm = last_line[0]
|
||
last_pos = last_line[-1]
|
||
dirn = determine_direction(frm, last_pos)
|
||
if len(last_line) == 1 or dir_equals(dirn, Middle):
|
||
dirn = fallback_dir
|
||
|
||
if not graph.config.useAscii:
|
||
if dir_equals(dirn, Up):
|
||
ch = "▲"
|
||
elif dir_equals(dirn, Down):
|
||
ch = "▼"
|
||
elif dir_equals(dirn, Left):
|
||
ch = "◄"
|
||
elif dir_equals(dirn, Right):
|
||
ch = "►"
|
||
elif dir_equals(dirn, UpperRight):
|
||
ch = "◥"
|
||
elif dir_equals(dirn, UpperLeft):
|
||
ch = "◤"
|
||
elif dir_equals(dirn, LowerRight):
|
||
ch = "◢"
|
||
elif dir_equals(dirn, LowerLeft):
|
||
ch = "◣"
|
||
else:
|
||
if dir_equals(fallback_dir, Up):
|
||
ch = "▲"
|
||
elif dir_equals(fallback_dir, Down):
|
||
ch = "▼"
|
||
elif dir_equals(fallback_dir, Left):
|
||
ch = "◄"
|
||
elif dir_equals(fallback_dir, Right):
|
||
ch = "►"
|
||
elif dir_equals(fallback_dir, UpperRight):
|
||
ch = "◥"
|
||
elif dir_equals(fallback_dir, UpperLeft):
|
||
ch = "◤"
|
||
elif dir_equals(fallback_dir, LowerRight):
|
||
ch = "◢"
|
||
elif dir_equals(fallback_dir, LowerLeft):
|
||
ch = "◣"
|
||
else:
|
||
ch = "●"
|
||
else:
|
||
if dir_equals(dirn, Up):
|
||
ch = "^"
|
||
elif dir_equals(dirn, Down):
|
||
ch = "v"
|
||
elif dir_equals(dirn, Left):
|
||
ch = "<"
|
||
elif dir_equals(dirn, Right):
|
||
ch = ">"
|
||
else:
|
||
if dir_equals(fallback_dir, Up):
|
||
ch = "^"
|
||
elif dir_equals(fallback_dir, Down):
|
||
ch = "v"
|
||
elif dir_equals(fallback_dir, Left):
|
||
ch = "<"
|
||
elif dir_equals(fallback_dir, Right):
|
||
ch = ">"
|
||
else:
|
||
ch = "*"
|
||
|
||
canvas[last_pos.x][last_pos.y] = ch
|
||
return canvas
|
||
|
||
|
||
def draw_corners(graph: AsciiGraph, path: list[GridCoord]) -> Canvas:
|
||
canvas = copy_canvas(graph.canvas)
|
||
for idx in range(1, len(path) - 1):
|
||
coord = path[idx]
|
||
dc = grid_to_drawing_coord(graph, coord)
|
||
prev_dir = determine_direction(path[idx - 1], coord)
|
||
next_dir = determine_direction(coord, path[idx + 1])
|
||
|
||
if not graph.config.useAscii:
|
||
if (dir_equals(prev_dir, Right) and dir_equals(next_dir, Down)) or (
|
||
dir_equals(prev_dir, Up) and dir_equals(next_dir, Left)
|
||
):
|
||
corner = "┐"
|
||
elif (dir_equals(prev_dir, Right) and dir_equals(next_dir, Up)) or (
|
||
dir_equals(prev_dir, Down) and dir_equals(next_dir, Left)
|
||
):
|
||
corner = "┘"
|
||
elif (dir_equals(prev_dir, Left) and dir_equals(next_dir, Down)) or (
|
||
dir_equals(prev_dir, Up) and dir_equals(next_dir, Right)
|
||
):
|
||
corner = "┌"
|
||
elif (dir_equals(prev_dir, Left) and dir_equals(next_dir, Up)) or (
|
||
dir_equals(prev_dir, Down) and dir_equals(next_dir, Right)
|
||
):
|
||
corner = "└"
|
||
else:
|
||
corner = "+"
|
||
else:
|
||
corner = "+"
|
||
|
||
canvas[dc.x][dc.y] = corner
|
||
|
||
return canvas
|
||
|
||
|
||
def draw_arrow_label(graph: AsciiGraph, edge: AsciiEdge) -> Canvas:
|
||
canvas = copy_canvas(graph.canvas)
|
||
if not edge.text:
|
||
return canvas
|
||
drawing_line = line_to_drawing(graph, edge.labelLine)
|
||
draw_text_on_line(canvas, drawing_line, edge.text)
|
||
return canvas
|
||
|
||
|
||
def draw_text_on_line(canvas: Canvas, line: list[DrawingCoord], label: str) -> None:
|
||
if len(line) < 2:
|
||
return
|
||
min_x = min(line[0].x, line[1].x)
|
||
max_x = max(line[0].x, line[1].x)
|
||
min_y = min(line[0].y, line[1].y)
|
||
max_y = max(line[0].y, line[1].y)
|
||
middle_x = min_x + (max_x - min_x) // 2
|
||
middle_y = min_y + (max_y - min_y) // 2
|
||
start_x = middle_x - (len(label) // 2)
|
||
draw_text(canvas, DrawingCoord(start_x, middle_y), label)
|
||
|
||
|
||
def draw_subgraph_box(sg: AsciiSubgraph, graph: AsciiGraph) -> Canvas:
|
||
width = sg.maxX - sg.minX
|
||
height = sg.maxY - sg.minY
|
||
if width <= 0 or height <= 0:
|
||
return mk_canvas(0, 0)
|
||
|
||
frm = DrawingCoord(0, 0)
|
||
to = DrawingCoord(width, height)
|
||
canvas = mk_canvas(width, height)
|
||
|
||
if not graph.config.useAscii:
|
||
for x in range(frm.x + 1, to.x):
|
||
canvas[x][frm.y] = "─"
|
||
canvas[x][to.y] = "─"
|
||
for y in range(frm.y + 1, to.y):
|
||
canvas[frm.x][y] = "│"
|
||
canvas[to.x][y] = "│"
|
||
canvas[frm.x][frm.y] = "┌"
|
||
canvas[to.x][frm.y] = "┐"
|
||
canvas[frm.x][to.y] = "└"
|
||
canvas[to.x][to.y] = "┘"
|
||
else:
|
||
for x in range(frm.x + 1, to.x):
|
||
canvas[x][frm.y] = "-"
|
||
canvas[x][to.y] = "-"
|
||
for y in range(frm.y + 1, to.y):
|
||
canvas[frm.x][y] = "|"
|
||
canvas[to.x][y] = "|"
|
||
canvas[frm.x][frm.y] = "+"
|
||
canvas[to.x][frm.y] = "+"
|
||
canvas[frm.x][to.y] = "+"
|
||
canvas[to.x][to.y] = "+"
|
||
|
||
return canvas
|
||
|
||
|
||
def draw_subgraph_label(
|
||
sg: AsciiSubgraph, graph: AsciiGraph
|
||
) -> tuple[Canvas, DrawingCoord]:
|
||
width = sg.maxX - sg.minX
|
||
height = sg.maxY - sg.minY
|
||
if width <= 0 or height <= 0:
|
||
return mk_canvas(0, 0), DrawingCoord(0, 0)
|
||
|
||
canvas = mk_canvas(width, height)
|
||
label_y = 1
|
||
label_x = (width // 2) - (len(sg.name) // 2)
|
||
if label_x < 1:
|
||
label_x = 1
|
||
|
||
for i, ch in enumerate(sg.name):
|
||
if label_x + i < width:
|
||
canvas[label_x + i][label_y] = ch
|
||
|
||
return canvas, DrawingCoord(sg.minX, sg.minY)
|
||
|
||
|
||
def sort_subgraphs_by_depth(subgraphs: list[AsciiSubgraph]) -> list[AsciiSubgraph]:
|
||
def depth(sg: AsciiSubgraph) -> int:
|
||
return 0 if sg.parent is None else 1 + depth(sg.parent)
|
||
|
||
return sorted(subgraphs, key=depth)
|
||
|
||
|
||
def draw_graph(graph: AsciiGraph) -> Canvas:
|
||
use_ascii = graph.config.useAscii
|
||
|
||
for sg in sort_subgraphs_by_depth(graph.subgraphs):
|
||
sg_canvas = draw_subgraph_box(sg, graph)
|
||
graph.canvas = merge_canvases(
|
||
graph.canvas, DrawingCoord(sg.minX, sg.minY), use_ascii, sg_canvas
|
||
)
|
||
|
||
for node in graph.nodes:
|
||
if not node.drawn and node.drawingCoord and node.drawing:
|
||
graph.canvas = merge_canvases(
|
||
graph.canvas, node.drawingCoord, use_ascii, node.drawing
|
||
)
|
||
node.drawn = True
|
||
|
||
line_canvases: list[Canvas] = []
|
||
corner_canvases: list[Canvas] = []
|
||
arrow_canvases: list[Canvas] = []
|
||
box_start_canvases: list[Canvas] = []
|
||
label_canvases: list[Canvas] = []
|
||
|
||
for edge in graph.edges:
|
||
path_c, box_start_c, arrow_c, corners_c, label_c = draw_arrow(graph, edge)
|
||
line_canvases.append(path_c)
|
||
corner_canvases.append(corners_c)
|
||
arrow_canvases.append(arrow_c)
|
||
box_start_canvases.append(box_start_c)
|
||
label_canvases.append(label_c)
|
||
|
||
zero = DrawingCoord(0, 0)
|
||
graph.canvas = merge_canvases(graph.canvas, zero, use_ascii, *line_canvases)
|
||
graph.canvas = merge_canvases(graph.canvas, zero, use_ascii, *corner_canvases)
|
||
graph.canvas = merge_canvases(graph.canvas, zero, use_ascii, *arrow_canvases)
|
||
graph.canvas = merge_canvases(graph.canvas, zero, use_ascii, *box_start_canvases)
|
||
graph.canvas = merge_canvases(graph.canvas, zero, use_ascii, *label_canvases)
|
||
|
||
for sg in graph.subgraphs:
|
||
if not sg.nodes:
|
||
continue
|
||
label_canvas, offset = draw_subgraph_label(sg, graph)
|
||
graph.canvas = merge_canvases(graph.canvas, offset, use_ascii, label_canvas)
|
||
|
||
return graph.canvas
|
||
|
||
|
||
# =============================================================================
|
||
# Sequence renderer
|
||
# =============================================================================
|
||
|
||
|
||
def render_sequence_ascii(text: str, config: AsciiConfig) -> str:
|
||
lines = [
|
||
l.strip()
|
||
for l in text.split("\n")
|
||
if l.strip() and not l.strip().startswith("%%")
|
||
]
|
||
diagram = parse_sequence_diagram(lines)
|
||
if not diagram.actors:
|
||
return ""
|
||
|
||
use_ascii = config.useAscii
|
||
|
||
H = "-" if use_ascii else "─"
|
||
V = "|" if use_ascii else "│"
|
||
TL = "+" if use_ascii else "┌"
|
||
TR = "+" if use_ascii else "┐"
|
||
BL = "+" if use_ascii else "└"
|
||
BR = "+" if use_ascii else "┘"
|
||
JT = "+" if use_ascii else "┬"
|
||
JB = "+" if use_ascii else "┴"
|
||
JL = "+" if use_ascii else "├"
|
||
JR = "+" if use_ascii else "┤"
|
||
|
||
actor_idx: dict[str, int] = {a.id: i for i, a in enumerate(diagram.actors)}
|
||
|
||
box_pad = 1
|
||
actor_box_widths = [len(a.label) + 2 * box_pad + 2 for a in diagram.actors]
|
||
half_box = [((w + 1) // 2) for w in actor_box_widths]
|
||
actor_box_h = 3
|
||
|
||
adj_max_width = [0] * max(len(diagram.actors) - 1, 0)
|
||
for msg in diagram.messages:
|
||
fi = actor_idx[msg.from_id]
|
||
ti = actor_idx[msg.to_id]
|
||
if fi == ti:
|
||
continue
|
||
lo = min(fi, ti)
|
||
hi = max(fi, ti)
|
||
needed = len(msg.label) + 4
|
||
num_gaps = hi - lo
|
||
per_gap = (needed + num_gaps - 1) // num_gaps
|
||
for g in range(lo, hi):
|
||
adj_max_width[g] = max(adj_max_width[g], per_gap)
|
||
|
||
ll_x = [half_box[0]]
|
||
for i in range(1, len(diagram.actors)):
|
||
gap = max(
|
||
half_box[i - 1] + half_box[i] + 2,
|
||
adj_max_width[i - 1] + 2,
|
||
8,
|
||
)
|
||
ll_x.append(ll_x[i - 1] + gap)
|
||
|
||
msg_arrow_y: list[int] = []
|
||
msg_label_y: list[int] = []
|
||
block_start_y: dict[int, int] = {}
|
||
block_end_y: dict[int, int] = {}
|
||
div_y_map: dict[str, int] = {}
|
||
note_positions: list[dict[str, object]] = []
|
||
|
||
cur_y = actor_box_h
|
||
|
||
for m_idx, msg in enumerate(diagram.messages):
|
||
for b_idx, block in enumerate(diagram.blocks):
|
||
if block.startIndex == m_idx:
|
||
cur_y += 2
|
||
block_start_y[b_idx] = cur_y - 1
|
||
|
||
for b_idx, block in enumerate(diagram.blocks):
|
||
for d_idx, div in enumerate(block.dividers):
|
||
if div.index == m_idx:
|
||
cur_y += 1
|
||
div_y_map[f"{b_idx}:{d_idx}"] = cur_y
|
||
cur_y += 1
|
||
|
||
cur_y += 1
|
||
|
||
is_self = msg.from_id == msg.to_id
|
||
if is_self:
|
||
msg_label_y.append(cur_y + 1)
|
||
msg_arrow_y.append(cur_y)
|
||
cur_y += 3
|
||
else:
|
||
msg_label_y.append(cur_y)
|
||
msg_arrow_y.append(cur_y + 1)
|
||
cur_y += 2
|
||
|
||
for note in diagram.notes:
|
||
if note.afterIndex == m_idx:
|
||
cur_y += 1
|
||
n_lines = note.text.split("\\n")
|
||
n_width = max(len(l) for l in n_lines) + 4
|
||
n_height = len(n_lines) + 2
|
||
|
||
a_idx = actor_idx.get(note.actorIds[0], 0)
|
||
if note.position == "left":
|
||
nx = ll_x[a_idx] - n_width - 1
|
||
elif note.position == "right":
|
||
nx = ll_x[a_idx] + 2
|
||
else:
|
||
if len(note.actorIds) >= 2:
|
||
a_idx2 = actor_idx.get(note.actorIds[1], a_idx)
|
||
nx = (ll_x[a_idx] + ll_x[a_idx2]) // 2 - (n_width // 2)
|
||
else:
|
||
nx = ll_x[a_idx] - (n_width // 2)
|
||
nx = max(0, nx)
|
||
|
||
note_positions.append(
|
||
{
|
||
"x": nx,
|
||
"y": cur_y,
|
||
"width": n_width,
|
||
"height": n_height,
|
||
"lines": n_lines,
|
||
}
|
||
)
|
||
cur_y += n_height
|
||
|
||
for b_idx, block in enumerate(diagram.blocks):
|
||
if block.endIndex == m_idx:
|
||
cur_y += 1
|
||
block_end_y[b_idx] = cur_y
|
||
cur_y += 1
|
||
|
||
cur_y += 1
|
||
footer_y = cur_y
|
||
total_h = footer_y + actor_box_h
|
||
|
||
last_ll = ll_x[-1] if ll_x else 0
|
||
last_half = half_box[-1] if half_box else 0
|
||
total_w = last_ll + last_half + 2
|
||
|
||
for msg in diagram.messages:
|
||
if msg.from_id == msg.to_id:
|
||
fi = actor_idx[msg.from_id]
|
||
self_right = ll_x[fi] + 6 + 2 + len(msg.label)
|
||
total_w = max(total_w, self_right + 1)
|
||
for np in note_positions:
|
||
total_w = max(total_w, np["x"] + np["width"] + 1)
|
||
|
||
canvas = mk_canvas(total_w, total_h - 1)
|
||
|
||
def draw_actor_box(cx: int, top_y: int, label: str) -> None:
|
||
w = len(label) + 2 * box_pad + 2
|
||
left = cx - (w // 2)
|
||
canvas[left][top_y] = TL
|
||
for x in range(1, w - 1):
|
||
canvas[left + x][top_y] = H
|
||
canvas[left + w - 1][top_y] = TR
|
||
canvas[left][top_y + 1] = V
|
||
canvas[left + w - 1][top_y + 1] = V
|
||
ls = left + 1 + box_pad
|
||
for i, ch in enumerate(label):
|
||
canvas[ls + i][top_y + 1] = ch
|
||
canvas[left][top_y + 2] = BL
|
||
for x in range(1, w - 1):
|
||
canvas[left + x][top_y + 2] = H
|
||
canvas[left + w - 1][top_y + 2] = BR
|
||
|
||
for i in range(len(diagram.actors)):
|
||
x = ll_x[i]
|
||
for y in range(actor_box_h, footer_y + 1):
|
||
canvas[x][y] = V
|
||
|
||
for i, actor in enumerate(diagram.actors):
|
||
draw_actor_box(ll_x[i], 0, actor.label)
|
||
draw_actor_box(ll_x[i], footer_y, actor.label)
|
||
if not use_ascii:
|
||
canvas[ll_x[i]][actor_box_h - 1] = JT
|
||
canvas[ll_x[i]][footer_y] = JB
|
||
|
||
for m_idx, msg in enumerate(diagram.messages):
|
||
fi = actor_idx[msg.from_id]
|
||
ti = actor_idx[msg.to_id]
|
||
from_x = ll_x[fi]
|
||
to_x = ll_x[ti]
|
||
is_self = fi == ti
|
||
is_dashed = msg.lineStyle == "dashed"
|
||
is_filled = msg.arrowHead == "filled"
|
||
|
||
line_char = "." if (is_dashed and use_ascii) else ("╌" if is_dashed else H)
|
||
|
||
if is_self:
|
||
top_y = msg_arrow_y[m_idx]
|
||
mid_y = msg_label_y[m_idx]
|
||
bot_y = top_y + 2
|
||
loop_x = from_x + 6
|
||
|
||
canvas[from_x][top_y] = JL if not use_ascii else "+"
|
||
for x in range(from_x + 1, loop_x):
|
||
canvas[x][top_y] = line_char
|
||
canvas[loop_x][top_y] = TR if not use_ascii else "+"
|
||
|
||
for y in range(top_y + 1, bot_y):
|
||
canvas[loop_x][y] = V
|
||
|
||
arrow_head = "<" if use_ascii else ("◄" if is_filled else "◁")
|
||
canvas[loop_x][bot_y] = BL if not use_ascii else "+"
|
||
for x in range(from_x + 1, loop_x):
|
||
canvas[x][bot_y] = line_char
|
||
canvas[from_x][bot_y] = arrow_head
|
||
|
||
label_start = from_x + 2
|
||
for i, ch in enumerate(msg.label):
|
||
canvas[label_start + i][mid_y] = ch
|
||
continue
|
||
|
||
label_y = msg_label_y[m_idx]
|
||
arrow_y = msg_arrow_y[m_idx]
|
||
|
||
label_start = min(from_x, to_x) + 2
|
||
for i, ch in enumerate(msg.label):
|
||
canvas[label_start + i][label_y] = ch
|
||
|
||
if from_x < to_x:
|
||
for x in range(from_x + 1, to_x):
|
||
canvas[x][arrow_y] = line_char
|
||
arrow_head = ">" if use_ascii else ("▶" if is_filled else "▷")
|
||
canvas[to_x][arrow_y] = arrow_head
|
||
else:
|
||
for x in range(to_x + 1, from_x):
|
||
canvas[x][arrow_y] = line_char
|
||
arrow_head = "<" if use_ascii else ("◀" if is_filled else "◁")
|
||
canvas[to_x][arrow_y] = arrow_head
|
||
|
||
for b_idx, block in enumerate(diagram.blocks):
|
||
start_y = block_start_y.get(b_idx)
|
||
end_y = block_end_y.get(b_idx)
|
||
if start_y is None or end_y is None:
|
||
continue
|
||
left = min(ll_x)
|
||
right = max(ll_x)
|
||
top = start_y
|
||
bottom = end_y
|
||
|
||
canvas[left - 2][top] = TL
|
||
for x in range(left - 1, right + 2):
|
||
canvas[x][top] = H
|
||
canvas[right + 2][top] = TR
|
||
|
||
canvas[left - 2][bottom] = BL
|
||
for x in range(left - 1, right + 2):
|
||
canvas[x][bottom] = H
|
||
canvas[right + 2][bottom] = BR
|
||
|
||
for y in range(top + 1, bottom):
|
||
canvas[left - 2][y] = V
|
||
canvas[right + 2][y] = V
|
||
|
||
header = f"{block.type} {block.label}".strip()
|
||
for i, ch in enumerate(header):
|
||
canvas[left - 1 + i][top + 1] = ch
|
||
|
||
for d_idx, div in enumerate(block.dividers):
|
||
dy = div_y_map.get(f"{b_idx}:{d_idx}")
|
||
if dy is None:
|
||
continue
|
||
canvas[left - 2][dy] = JL
|
||
for x in range(left - 1, right + 2):
|
||
canvas[x][dy] = H
|
||
canvas[right + 2][dy] = JR
|
||
label = f"{div.label}".strip()
|
||
for i, ch in enumerate(label):
|
||
canvas[left - 1 + i][dy + 1] = ch
|
||
|
||
for np in note_positions:
|
||
nx = np["x"]
|
||
ny = np["y"]
|
||
n_width = np["width"]
|
||
n_height = np["height"]
|
||
lines = np["lines"]
|
||
canvas[nx][ny] = TL
|
||
for x in range(1, n_width - 1):
|
||
canvas[nx + x][ny] = H
|
||
canvas[nx + n_width - 1][ny] = TR
|
||
canvas[nx][ny + n_height - 1] = BL
|
||
for x in range(1, n_width - 1):
|
||
canvas[nx + x][ny + n_height - 1] = H
|
||
canvas[nx + n_width - 1][ny + n_height - 1] = BR
|
||
for y in range(1, n_height - 1):
|
||
canvas[nx][ny + y] = V
|
||
canvas[nx + n_width - 1][ny + y] = V
|
||
for i, line in enumerate(lines):
|
||
start_x = nx + 2
|
||
for j, ch in enumerate(line):
|
||
canvas[start_x + j][ny + 1 + i] = ch
|
||
|
||
return canvas_to_string(canvas)
|
||
|
||
|
||
# =============================================================================
|
||
# Class diagram renderer
|
||
# =============================================================================
|
||
|
||
|
||
def format_member(m: ClassMember) -> str:
|
||
vis = m.visibility or ""
|
||
typ = f": {m.type}" if m.type else ""
|
||
return f"{vis}{m.name}{typ}"
|
||
|
||
|
||
def build_class_sections(cls: ClassNode) -> list[list[str]]:
|
||
header: list[str] = []
|
||
if cls.annotation:
|
||
header.append(f"<<{cls.annotation}>>")
|
||
header.append(cls.label)
|
||
attrs = [format_member(m) for m in cls.attributes]
|
||
methods = [format_member(m) for m in cls.methods]
|
||
if not attrs and not methods:
|
||
return [header]
|
||
if not methods:
|
||
return [header, attrs]
|
||
return [header, attrs, methods]
|
||
|
||
|
||
def get_marker_shape(
|
||
rel_type: str, use_ascii: bool, direction: str | None = None
|
||
) -> str:
|
||
if rel_type in ("inheritance", "realization"):
|
||
if direction == "down":
|
||
return "^" if use_ascii else "△"
|
||
if direction == "up":
|
||
return "v" if use_ascii else "▽"
|
||
if direction == "left":
|
||
return ">" if use_ascii else "◁"
|
||
return "<" if use_ascii else "▷"
|
||
if rel_type == "composition":
|
||
return "*" if use_ascii else "◆"
|
||
if rel_type == "aggregation":
|
||
return "o" if use_ascii else "◇"
|
||
if rel_type in ("association", "dependency"):
|
||
if direction == "down":
|
||
return "v" if use_ascii else "▼"
|
||
if direction == "up":
|
||
return "^" if use_ascii else "▲"
|
||
if direction == "left":
|
||
return "<" if use_ascii else "◀"
|
||
return ">" if use_ascii else "▶"
|
||
return ">"
|
||
|
||
|
||
def render_class_ascii(text: str, config: AsciiConfig) -> str:
|
||
lines = [
|
||
l.strip()
|
||
for l in text.split("\n")
|
||
if l.strip() and not l.strip().startswith("%%")
|
||
]
|
||
diagram = parse_class_diagram(lines)
|
||
if not diagram.classes:
|
||
return ""
|
||
|
||
use_ascii = config.useAscii
|
||
h_gap = 4
|
||
v_gap = 3
|
||
|
||
class_sections: dict[str, list[list[str]]] = {}
|
||
class_box_w: dict[str, int] = {}
|
||
class_box_h: dict[str, int] = {}
|
||
|
||
for cls in diagram.classes:
|
||
sections = build_class_sections(cls)
|
||
class_sections[cls.id] = sections
|
||
max_text = 0
|
||
for section in sections:
|
||
for line in section:
|
||
max_text = max(max_text, len(line))
|
||
box_w = max_text + 4
|
||
total_lines = 0
|
||
for section in sections:
|
||
total_lines += max(len(section), 1)
|
||
box_h = total_lines + (len(sections) - 1) + 2
|
||
class_box_w[cls.id] = box_w
|
||
class_box_h[cls.id] = box_h
|
||
|
||
class_by_id = {c.id: c for c in diagram.classes}
|
||
parents: dict[str, set[str]] = {}
|
||
children: dict[str, set[str]] = {}
|
||
|
||
for rel in diagram.relationships:
|
||
is_hier = rel.type in ("inheritance", "realization")
|
||
parent_id = rel.to_id if (is_hier and rel.markerAt == "to") else rel.from_id
|
||
child_id = rel.from_id if (is_hier and rel.markerAt == "to") else rel.to_id
|
||
parents.setdefault(child_id, set()).add(parent_id)
|
||
children.setdefault(parent_id, set()).add(child_id)
|
||
|
||
level: dict[str, int] = {}
|
||
roots = [c for c in diagram.classes if (c.id not in parents or not parents[c.id])]
|
||
queue = [c.id for c in roots]
|
||
for cid in queue:
|
||
level[cid] = 0
|
||
|
||
level_cap = max(len(diagram.classes) - 1, 0)
|
||
qi = 0
|
||
while qi < len(queue):
|
||
cid = queue[qi]
|
||
qi += 1
|
||
child_set = children.get(cid)
|
||
if not child_set:
|
||
continue
|
||
for child_id in child_set:
|
||
new_level = level.get(cid, 0) + 1
|
||
if new_level > level_cap:
|
||
continue
|
||
if (child_id not in level) or (level[child_id] < new_level):
|
||
level[child_id] = new_level
|
||
queue.append(child_id)
|
||
|
||
for cls in diagram.classes:
|
||
if cls.id not in level:
|
||
level[cls.id] = 0
|
||
|
||
max_level = max(level.values()) if level else 0
|
||
level_groups = [[] for _ in range(max_level + 1)]
|
||
for cls in diagram.classes:
|
||
level_groups[level[cls.id]].append(cls.id)
|
||
|
||
placed: dict[str, dict[str, object]] = {}
|
||
current_y = 0
|
||
|
||
for lv in range(max_level + 1):
|
||
group = level_groups[lv]
|
||
if not group:
|
||
continue
|
||
current_x = 0
|
||
max_h = 0
|
||
for cid in group:
|
||
cls = class_by_id[cid]
|
||
w = class_box_w[cid]
|
||
h = class_box_h[cid]
|
||
placed[cid] = {
|
||
"cls": cls,
|
||
"sections": class_sections[cid],
|
||
"x": current_x,
|
||
"y": current_y,
|
||
"width": w,
|
||
"height": h,
|
||
}
|
||
current_x += w + h_gap
|
||
max_h = max(max_h, h)
|
||
current_y += max_h + v_gap
|
||
|
||
total_w = 0
|
||
total_h = 0
|
||
for p in placed.values():
|
||
total_w = max(total_w, p["x"] + p["width"])
|
||
total_h = max(total_h, p["y"] + p["height"])
|
||
total_w += 2
|
||
total_h += 2
|
||
|
||
canvas = mk_canvas(total_w - 1, total_h - 1)
|
||
|
||
for p in placed.values():
|
||
box_canvas = draw_multi_box(p["sections"], use_ascii)
|
||
for bx in range(len(box_canvas)):
|
||
for by in range(len(box_canvas[0])):
|
||
ch = box_canvas[bx][by]
|
||
if ch != " ":
|
||
cx = p["x"] + bx
|
||
cy = p["y"] + by
|
||
if cx < total_w and cy < total_h:
|
||
canvas[cx][cy] = ch
|
||
|
||
def box_bounds(cid: str) -> tuple[int, int, int, int]:
|
||
p = placed[cid]
|
||
return (p["x"], p["y"], p["x"] + p["width"] - 1, p["y"] + p["height"] - 1)
|
||
|
||
def h_segment_hits_box(y: int, x1: int, x2: int, skip: set[str]) -> bool:
|
||
a = min(x1, x2)
|
||
b = max(x1, x2)
|
||
for cid in placed:
|
||
if cid in skip:
|
||
continue
|
||
bx0, by0, bx1, by1 = box_bounds(cid)
|
||
if by0 <= y <= by1 and not (b < bx0 or a > bx1):
|
||
return True
|
||
return False
|
||
|
||
def v_segment_hits_box(x: int, y1: int, y2: int, skip: set[str]) -> bool:
|
||
a = min(y1, y2)
|
||
b = max(y1, y2)
|
||
for cid in placed:
|
||
if cid in skip:
|
||
continue
|
||
bx0, by0, bx1, by1 = box_bounds(cid)
|
||
if bx0 <= x <= bx1 and not (b < by0 or a > by1):
|
||
return True
|
||
return False
|
||
|
||
pending_markers: list[tuple[int, int, str]] = []
|
||
pending_labels: list[tuple[int, int, str]] = []
|
||
label_spans: list[tuple[int, int, int]] = []
|
||
|
||
for rel in diagram.relationships:
|
||
c1 = placed.get(rel.from_id)
|
||
c2 = placed.get(rel.to_id)
|
||
if not c1 or not c2:
|
||
continue
|
||
|
||
x1 = c1["x"] + c1["width"] // 2
|
||
y1 = c1["y"] + c1["height"]
|
||
x2 = c2["x"] + c2["width"] // 2
|
||
y2 = c2["y"] - 1
|
||
|
||
start_x, start_y = x1, y1
|
||
end_x, end_y = x2, y2
|
||
|
||
mid_y = (start_y + end_y) // 2
|
||
skip_boxes = {rel.from_id, rel.to_id}
|
||
if h_segment_hits_box(mid_y, start_x, end_x, skip_boxes):
|
||
for delta in range(1, total_h + 1):
|
||
moved = False
|
||
for candidate in (mid_y - delta, mid_y + delta):
|
||
if not (0 <= candidate < total_h):
|
||
continue
|
||
if h_segment_hits_box(candidate, start_x, end_x, skip_boxes):
|
||
continue
|
||
mid_y = candidate
|
||
moved = True
|
||
break
|
||
if moved:
|
||
break
|
||
line_char = (
|
||
"."
|
||
if (rel.type in ("dependency", "realization") and use_ascii)
|
||
else ("╌" if rel.type in ("dependency", "realization") else "-")
|
||
)
|
||
v_char = (
|
||
":"
|
||
if (rel.type in ("dependency", "realization") and use_ascii)
|
||
else ("┊" if rel.type in ("dependency", "realization") else "|")
|
||
)
|
||
if not use_ascii:
|
||
line_char = "╌" if rel.type in ("dependency", "realization") else "─"
|
||
v_char = "┊" if rel.type in ("dependency", "realization") else "│"
|
||
|
||
for y in range(start_y, mid_y + 1):
|
||
if 0 <= start_x < total_w and 0 <= y < total_h:
|
||
canvas[start_x][y] = v_char
|
||
step = 1 if end_x >= start_x else -1
|
||
for x in range(start_x, end_x + step, step):
|
||
if 0 <= x < total_w and 0 <= mid_y < total_h:
|
||
canvas[x][mid_y] = line_char
|
||
for y in range(mid_y, end_y + 1):
|
||
if 0 <= end_x < total_w and 0 <= y < total_h:
|
||
canvas[end_x][y] = v_char
|
||
|
||
if rel.markerAt == "from":
|
||
direction = "down"
|
||
marker_x, marker_y = start_x, start_y - 1
|
||
else:
|
||
direction = "up"
|
||
marker_x, marker_y = end_x, end_y + 1
|
||
|
||
marker = get_marker_shape(rel.type, use_ascii, direction)
|
||
if 0 <= marker_x < total_w and 0 <= marker_y < total_h:
|
||
pending_markers.append((marker_x, marker_y, marker))
|
||
|
||
if rel.label:
|
||
label_x = (start_x + end_x) // 2 - (len(rel.label) // 2)
|
||
label_x = max(0, label_x)
|
||
label_y = mid_y - 1
|
||
if label_y >= 0:
|
||
lx1 = label_x
|
||
lx2 = label_x + len(rel.label) - 1
|
||
placed_label = False
|
||
for dy in (0, -1, 1, -2, 2):
|
||
cy = label_y + dy
|
||
if not (0 <= cy < total_h):
|
||
continue
|
||
overlap = False
|
||
for sy, sx1, sx2 in label_spans:
|
||
if sy == cy and not (lx2 < sx1 or lx1 > sx2):
|
||
overlap = True
|
||
break
|
||
if overlap:
|
||
continue
|
||
pending_labels.append((label_x, cy, rel.label))
|
||
label_spans.append((cy, lx1, lx2))
|
||
placed_label = True
|
||
break
|
||
if not placed_label:
|
||
pending_labels.append((label_x, label_y, rel.label))
|
||
|
||
if rel.fromCardinality:
|
||
text = rel.fromCardinality
|
||
for i, ch in enumerate(text):
|
||
lx = start_x - len(text) - 1 + i
|
||
ly = start_y - 1
|
||
if 0 <= lx < total_w and 0 <= ly < total_h:
|
||
canvas[lx][ly] = ch
|
||
if rel.toCardinality:
|
||
text = rel.toCardinality
|
||
for i, ch in enumerate(text):
|
||
lx = end_x + 1 + i
|
||
ly = end_y + 1
|
||
if 0 <= lx < total_w and 0 <= ly < total_h:
|
||
canvas[lx][ly] = ch
|
||
|
||
for mx, my, marker in pending_markers:
|
||
canvas[mx][my] = marker
|
||
for lx, ly, text in pending_labels:
|
||
for i, ch in enumerate(text):
|
||
x = lx + i
|
||
if 0 <= x < total_w and 0 <= ly < total_h:
|
||
canvas[x][ly] = ch
|
||
|
||
return canvas_to_string(canvas)
|
||
|
||
|
||
# =============================================================================
|
||
# ER diagram renderer
|
||
# =============================================================================
|
||
|
||
|
||
def format_attribute(attr: ErAttribute) -> str:
|
||
key_str = (",".join(attr.keys) + " ") if attr.keys else " "
|
||
return f"{key_str}{attr.type} {attr.name}"
|
||
|
||
|
||
def build_entity_sections(entity: ErEntity) -> list[list[str]]:
|
||
header = [entity.label]
|
||
attrs = [format_attribute(a) for a in entity.attributes]
|
||
return [header] if not attrs else [header, attrs]
|
||
|
||
|
||
def get_crows_foot_chars(card: str, use_ascii: bool) -> str:
|
||
if use_ascii:
|
||
if card == "one":
|
||
return "||"
|
||
if card == "zero-one":
|
||
return "o|"
|
||
if card == "many":
|
||
return "}|"
|
||
if card == "zero-many":
|
||
return "o{"
|
||
else:
|
||
if card == "one":
|
||
return "║"
|
||
if card == "zero-one":
|
||
return "o║"
|
||
if card == "many":
|
||
return "╟"
|
||
if card == "zero-many":
|
||
return "o╟"
|
||
return "||"
|
||
|
||
|
||
def render_er_ascii(text: str, config: AsciiConfig) -> str:
|
||
lines = [
|
||
l.strip()
|
||
for l in text.split("\n")
|
||
if l.strip() and not l.strip().startswith("%%")
|
||
]
|
||
diagram = parse_er_diagram(lines)
|
||
if not diagram.entities:
|
||
return ""
|
||
|
||
use_ascii = config.useAscii
|
||
h_gap = 6
|
||
v_gap = 3
|
||
|
||
entity_sections: dict[str, list[list[str]]] = {}
|
||
entity_box_w: dict[str, int] = {}
|
||
entity_box_h: dict[str, int] = {}
|
||
|
||
for ent in diagram.entities:
|
||
sections = build_entity_sections(ent)
|
||
entity_sections[ent.id] = sections
|
||
max_text = 0
|
||
for section in sections:
|
||
for line in section:
|
||
max_text = max(max_text, len(line))
|
||
box_w = max_text + 4
|
||
total_lines = 0
|
||
for section in sections:
|
||
total_lines += max(len(section), 1)
|
||
box_h = total_lines + (len(sections) - 1) + 2
|
||
entity_box_w[ent.id] = box_w
|
||
entity_box_h[ent.id] = box_h
|
||
|
||
max_per_row = max(2, int((len(diagram.entities) ** 0.5) + 0.999))
|
||
|
||
placed: dict[str, dict[str, object]] = {}
|
||
current_x = 0
|
||
current_y = 0
|
||
max_row_h = 0
|
||
col_count = 0
|
||
|
||
for ent in diagram.entities:
|
||
w = entity_box_w[ent.id]
|
||
h = entity_box_h[ent.id]
|
||
if col_count >= max_per_row:
|
||
current_y += max_row_h + v_gap
|
||
current_x = 0
|
||
max_row_h = 0
|
||
col_count = 0
|
||
placed[ent.id] = {
|
||
"entity": ent,
|
||
"sections": entity_sections[ent.id],
|
||
"x": current_x,
|
||
"y": current_y,
|
||
"width": w,
|
||
"height": h,
|
||
}
|
||
current_x += w + h_gap
|
||
max_row_h = max(max_row_h, h)
|
||
col_count += 1
|
||
|
||
total_w = 0
|
||
total_h = 0
|
||
for p in placed.values():
|
||
total_w = max(total_w, p["x"] + p["width"])
|
||
total_h = max(total_h, p["y"] + p["height"])
|
||
total_w += 4
|
||
total_h += 1
|
||
|
||
canvas = mk_canvas(total_w - 1, total_h - 1)
|
||
|
||
for p in placed.values():
|
||
box_canvas = draw_multi_box(p["sections"], use_ascii)
|
||
for bx in range(len(box_canvas)):
|
||
for by in range(len(box_canvas[0])):
|
||
ch = box_canvas[bx][by]
|
||
if ch != " ":
|
||
cx = p["x"] + bx
|
||
cy = p["y"] + by
|
||
if cx < total_w and cy < total_h:
|
||
canvas[cx][cy] = ch
|
||
|
||
H = "-" if use_ascii else "─"
|
||
V = "|" if use_ascii else "│"
|
||
dash_h = "." if use_ascii else "╌"
|
||
dash_v = ":" if use_ascii else "┊"
|
||
|
||
for rel in diagram.relationships:
|
||
e1 = placed.get(rel.entity1)
|
||
e2 = placed.get(rel.entity2)
|
||
if not e1 or not e2:
|
||
continue
|
||
|
||
line_h = H if rel.identifying else dash_h
|
||
line_v = V if rel.identifying else dash_v
|
||
|
||
e1_cx = e1["x"] + e1["width"] // 2
|
||
e1_cy = e1["y"] + e1["height"] // 2
|
||
e2_cx = e2["x"] + e2["width"] // 2
|
||
e2_cy = e2["y"] + e2["height"] // 2
|
||
|
||
same_row = abs(e1_cy - e2_cy) < max(e1["height"], e2["height"])
|
||
|
||
if same_row:
|
||
left, right = (e1, e2) if e1_cx < e2_cx else (e2, e1)
|
||
left_card, right_card = (
|
||
(rel.cardinality1, rel.cardinality2)
|
||
if e1_cx < e2_cx
|
||
else (rel.cardinality2, rel.cardinality1)
|
||
)
|
||
start_x = left["x"] + left["width"]
|
||
end_x = right["x"] - 1
|
||
line_y = left["y"] + left["height"] // 2
|
||
|
||
for x in range(start_x, end_x + 1):
|
||
if x < total_w:
|
||
canvas[x][line_y] = line_h
|
||
|
||
left_chars = get_crows_foot_chars(left_card, use_ascii)
|
||
for i, ch in enumerate(left_chars):
|
||
mx = start_x + i
|
||
if mx < total_w:
|
||
canvas[mx][line_y] = ch
|
||
|
||
right_chars = get_crows_foot_chars(right_card, use_ascii)
|
||
for i, ch in enumerate(right_chars):
|
||
mx = end_x - len(right_chars) + 1 + i
|
||
if 0 <= mx < total_w:
|
||
canvas[mx][line_y] = ch
|
||
|
||
if rel.label:
|
||
gap_mid = (start_x + end_x) // 2
|
||
label_start = max(start_x, gap_mid - (len(rel.label) // 2))
|
||
label_y = line_y - 1
|
||
if label_y >= 0:
|
||
for i, ch in enumerate(rel.label):
|
||
lx = label_start + i
|
||
if start_x <= lx <= end_x and lx < total_w:
|
||
canvas[lx][label_y] = ch
|
||
else:
|
||
upper, lower = (e1, e2) if e1_cy < e2_cy else (e2, e1)
|
||
upper_card, lower_card = (
|
||
(rel.cardinality1, rel.cardinality2)
|
||
if e1_cy < e2_cy
|
||
else (rel.cardinality2, rel.cardinality1)
|
||
)
|
||
start_y = upper["y"] + upper["height"]
|
||
end_y = lower["y"] - 1
|
||
line_x = upper["x"] + upper["width"] // 2
|
||
|
||
for y in range(start_y, end_y + 1):
|
||
if y < total_h:
|
||
canvas[line_x][y] = line_v
|
||
|
||
up_chars = get_crows_foot_chars(upper_card, use_ascii)
|
||
if use_ascii:
|
||
uy = start_y
|
||
for i, ch in enumerate(up_chars):
|
||
if line_x + i < total_w:
|
||
canvas[line_x + i][uy] = ch
|
||
else:
|
||
uy = start_y
|
||
if len(up_chars) == 1:
|
||
canvas[line_x][uy] = up_chars
|
||
else:
|
||
canvas[line_x - 1][uy] = up_chars[0]
|
||
canvas[line_x][uy] = up_chars[1]
|
||
|
||
low_chars = get_crows_foot_chars(lower_card, use_ascii)
|
||
if use_ascii:
|
||
ly = end_y
|
||
for i, ch in enumerate(low_chars):
|
||
if line_x + i < total_w:
|
||
canvas[line_x + i][ly] = ch
|
||
else:
|
||
ly = end_y
|
||
if len(low_chars) == 1:
|
||
canvas[line_x][ly] = low_chars
|
||
else:
|
||
canvas[line_x - 1][ly] = low_chars[0]
|
||
canvas[line_x][ly] = low_chars[1]
|
||
|
||
if rel.label:
|
||
label_y = (start_y + end_y) // 2
|
||
label_x = line_x + 2
|
||
for i, ch in enumerate(rel.label):
|
||
lx = label_x + i
|
||
if lx < total_w and label_y < total_h:
|
||
canvas[lx][label_y] = ch
|
||
|
||
return canvas_to_string(canvas)
|
||
|
||
|
||
# =============================================================================
|
||
# Top-level render
|
||
# =============================================================================
|
||
|
||
|
||
def detect_diagram_type(text: str) -> str:
|
||
first_line = (
|
||
(text.strip().split("\n")[0].split(";")[0] if text.strip() else "")
|
||
.strip()
|
||
.lower()
|
||
)
|
||
if re.match(r"^sequencediagram\s*$", first_line):
|
||
return "sequence"
|
||
if re.match(r"^classdiagram\s*$", first_line):
|
||
return "class"
|
||
if re.match(r"^erdiagram\s*$", first_line):
|
||
return "er"
|
||
return "flowchart"
|
||
|
||
|
||
def render_mermaid_ascii(
|
||
text: str,
|
||
use_ascii: bool = False,
|
||
padding_x: int = 6,
|
||
padding_y: int = 4,
|
||
box_border_padding: int = 1,
|
||
) -> str:
|
||
config = AsciiConfig(
|
||
useAscii=use_ascii,
|
||
paddingX=padding_x,
|
||
paddingY=padding_y,
|
||
boxBorderPadding=box_border_padding,
|
||
graphDirection="TD",
|
||
)
|
||
|
||
diagram_type = detect_diagram_type(text)
|
||
|
||
if diagram_type == "sequence":
|
||
return render_sequence_ascii(text, config)
|
||
if diagram_type == "class":
|
||
return render_class_ascii(text, config)
|
||
if diagram_type == "er":
|
||
return render_er_ascii(text, config)
|
||
|
||
parsed = parse_mermaid(text)
|
||
if parsed.direction in ("LR", "RL"):
|
||
config.graphDirection = "LR"
|
||
else:
|
||
config.graphDirection = "TD"
|
||
|
||
graph = convert_to_ascii_graph(parsed, config)
|
||
create_mapping(graph)
|
||
draw_graph(graph)
|
||
|
||
if parsed.direction == "BT":
|
||
flip_canvas_vertically(graph.canvas)
|
||
|
||
return canvas_to_string(graph.canvas)
|
||
|
||
|
||
# =============================================================================
|
||
# CLI
|
||
# =============================================================================
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(
|
||
description="Render Mermaid diagrams to ASCII/Unicode."
|
||
)
|
||
parser.add_argument("input", help="Path to Mermaid text file")
|
||
parser.add_argument(
|
||
"--ascii",
|
||
action="store_true",
|
||
help="Use ASCII characters instead of Unicode box drawing",
|
||
)
|
||
parser.add_argument(
|
||
"--padding-x", type=int, default=6, help="Horizontal spacing between nodes"
|
||
)
|
||
parser.add_argument(
|
||
"--padding-y", type=int, default=4, help="Vertical spacing between nodes"
|
||
)
|
||
parser.add_argument(
|
||
"--box-padding", type=int, default=1, help="Padding inside node boxes"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
with open(args.input, encoding="utf-8") as f:
|
||
text = f.read()
|
||
|
||
output = render_mermaid_ascii(
|
||
text,
|
||
use_ascii=args.ascii,
|
||
padding_x=args.padding_x,
|
||
padding_y=args.padding_y,
|
||
box_border_padding=args.box_padding,
|
||
)
|
||
print(output)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|