#!/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()