termrender 4.1.0__tar.gz → 4.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {termrender-4.1.0 → termrender-4.2.0}/CHANGELOG.md +57 -0
- {termrender-4.1.0 → termrender-4.2.0}/PKG-INFO +1 -1
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/mermaid_pie.py +8 -2
- termrender-4.2.0/src/termrender/renderers/mermaid_sequence.py +597 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_pie.py +10 -0
- termrender-4.2.0/tests/test_mermaid_sequence.py +463 -0
- {termrender-4.1.0 → termrender-4.2.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/.gitignore +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/CLAUDE.md +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/LICENSE +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/README.md +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/design.json +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/pyproject.toml +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/requirements.json +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/__init__.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/__main__.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/blocks.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/emit.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/layout.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/parser.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/py.typed +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/src/termrender/style.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/__init__.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_charts.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_diff.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_linebreak.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_stat.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_tasklist.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_timeline.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/tests/test_variable_colons.py +0 -0
- {termrender-4.1.0 → termrender-4.2.0}/uv.lock +0 -0
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.2.0 (2026-07-06)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **mermaid-sequence**: Reject dash-adjacent punctuation as part of an id
|
|
9
|
+
([`612f36d`](https://github.com/crouton-labs/termrender/commit/612f36d14d68e6c51f4ac6d16f0ae837e44ddd01))
|
|
10
|
+
|
|
11
|
+
Reviewer found: near-miss arrow syntax with no sequence-diagram equivalent (e.g. flowchart-only
|
|
12
|
+
`A-.->B`, or garbage like `A==>>B`) was silently absorbed into the source id ("A-.") instead of
|
|
13
|
+
degrading to a plain line, because the arrow regex's identifier token was \S+/\S+? (anything
|
|
14
|
+
non-space) and would backtrack through dash/dot punctuation until some later '>' happened to
|
|
15
|
+
complete a valid-looking arrow.
|
|
16
|
+
|
|
17
|
+
Fix: identifiers now exclude '-' (plus ':', '+', whitespace) — dash is reserved for the arrow
|
|
18
|
+
itself, so an id is the maximal run up to the first dash, eliminating the backtracking ambiguity
|
|
19
|
+
entirely. Also documents the pre-existing (accepted, mirrors wrap_text()'s CJK bug elsewhere in
|
|
20
|
+
termrender) character-index vs visual-width limitation in the module docstring, per independent
|
|
21
|
+
review (non-blocking, not fixed here — same class of fix as the documented wrap_text() constraint,
|
|
22
|
+
out of proportion for a renderer not yet wired into the dispatcher).
|
|
23
|
+
|
|
24
|
+
Adds 2 regression tests for the repro cases. 191 tests pass (full suite, up from 189).
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
- Add native ASCII sequence-diagram renderer for mermaid
|
|
29
|
+
([`ad63977`](https://github.com/crouton-labs/termrender/commit/ad639774edcbcd744ba7f2183c4f5d8c83630af0))
|
|
30
|
+
|
|
31
|
+
Standalone module (src/termrender/renderers/mermaid_sequence.py) exposing render_sequence(source,
|
|
32
|
+
width) -> list[str]. Not wired into the mermaid.py dispatcher yet (a later integration phase owns
|
|
33
|
+
that).
|
|
34
|
+
|
|
35
|
+
Layout: participant boxes across the top/bottom, vertical lifelines, messages as horizontal arrows
|
|
36
|
+
in declaration order, self-messages as a loop-back. Column spacing is a 1D constraint pass driven
|
|
37
|
+
by participant label widths and the messages crossing each gap (no 2D layout search needed since
|
|
38
|
+
message order already fixes the vertical axis).
|
|
39
|
+
|
|
40
|
+
Grammar: participant/actor with 'as' aliases plus implicit participants from first use; all 8 arrow
|
|
41
|
+
forms (->, -->, ->>, -->>, -x, --x, -), --)) with distinct line style (solid/dashed) and arrowhead
|
|
42
|
+
glyph per marker; Note over/left of/right of; autonumber (start/step); loop/alt/opt/par/
|
|
43
|
+
critical/break/rect/box render as labeled separator bands (else/and/ option as mid-band
|
|
44
|
+
separators); activate/deactivate (including the +/- arrow shorthand) and <br/> are tolerated and
|
|
45
|
+
flattened; any other unrecognized or malformed line (stray else/end, unknown directives) degrades
|
|
46
|
+
to a plain text line rather than raising.
|
|
47
|
+
|
|
48
|
+
Tests: tests/test_mermaid_sequence.py, 40 cases covering golden output, all arrow variants, aliases,
|
|
49
|
+
implicit participant ordering, notes, autonumber, block nesting, degradation paths, and the
|
|
50
|
+
no-trailing-whitespace contract. Full suite (189 tests) still green.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## v4.1.1 (2026-07-06)
|
|
54
|
+
|
|
55
|
+
### Bug Fixes
|
|
56
|
+
|
|
57
|
+
- **mermaid**: Honor inline title on pie header line
|
|
58
|
+
([`37b4c37`](https://github.com/crouton-labs/termrender/commit/37b4c37581046d7a9b327f0e06f3ea5a5ca921d3))
|
|
59
|
+
|
|
60
|
+
|
|
4
61
|
## v4.1.0 (2026-07-06)
|
|
5
62
|
|
|
6
63
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: Rich terminal rendering of directive-flavored markdown
|
|
5
5
|
Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
|
|
6
6
|
Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
|
|
@@ -22,7 +22,7 @@ from termrender.renderers.charts import render_bar
|
|
|
22
22
|
|
|
23
23
|
_TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
|
|
24
24
|
_DATA_RE = re.compile(r'^\s*"([^"]*)"\s*:\s*(-?\d+(?:\.\d+)?)\s*$')
|
|
25
|
-
_HEADER_RE = re.compile(r"^\s*pie\b", re.IGNORECASE)
|
|
25
|
+
_HEADER_RE = re.compile(r"^\s*pie\b(?:\s+showData\b)?\s*(.*)$", re.IGNORECASE)
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def parse_pie(source: str) -> tuple[str | None, list[dict]]:
|
|
@@ -38,7 +38,13 @@ def parse_pie(source: str) -> tuple[str | None, list[dict]]:
|
|
|
38
38
|
for line in source.splitlines():
|
|
39
39
|
if not line.strip():
|
|
40
40
|
continue
|
|
41
|
-
|
|
41
|
+
m = _HEADER_RE.match(line)
|
|
42
|
+
if m:
|
|
43
|
+
# Mermaid allows the title inline on the header line:
|
|
44
|
+
# ``pie title X`` / ``pie showData title X``.
|
|
45
|
+
tm = _TITLE_RE.match(m.group(1))
|
|
46
|
+
if tm:
|
|
47
|
+
title = tm.group(1)
|
|
42
48
|
continue
|
|
43
49
|
m = _TITLE_RE.match(line)
|
|
44
50
|
if m:
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Native ASCII renderer for mermaid ``sequenceDiagram`` sources.
|
|
2
|
+
|
|
3
|
+
Standalone module: exposes a single pure function, ``render_sequence``.
|
|
4
|
+
Not wired into the ``mermaid.py`` dispatcher yet (a later integration
|
|
5
|
+
phase owns that); this module has no dependency on ``Block`` or the
|
|
6
|
+
rest of the renderer pipeline, and nothing here is imported elsewhere.
|
|
7
|
+
|
|
8
|
+
Layout model
|
|
9
|
+
------------
|
|
10
|
+
Participants become columns (a lifeline ``│`` per column, boxed labels
|
|
11
|
+
at top and bottom). Messages render in declaration order, top to bottom,
|
|
12
|
+
as horizontal arrows between two columns; a message from/to the same
|
|
13
|
+
column becomes a small loop drawn to the right of its lifeline. Column
|
|
14
|
+
spacing is computed once, bottom-up from participant label widths and
|
|
15
|
+
the messages that cross each gap (a message spanning two adjacent
|
|
16
|
+
columns needs a wide-enough gap; a message spanning several columns
|
|
17
|
+
distributes the requirement across the gaps it crosses) — this is a 1D
|
|
18
|
+
constraint pass, not a 2D layout search: message order already fixes
|
|
19
|
+
the vertical axis, and declaration order fixes the column axis.
|
|
20
|
+
|
|
21
|
+
Grammar supported
|
|
22
|
+
------------------
|
|
23
|
+
``participant``/``actor`` (with ``as`` aliases) and implicit participants
|
|
24
|
+
introduced by first use in a message or note; all eight core arrow forms
|
|
25
|
+
(``->``, ``-->``, ``->>``, ``-->>``, ``-x``, ``--x``, ``-)``, ``--)``);
|
|
26
|
+
``Note over/left of/right of``; ``autonumber`` (with optional start/step);
|
|
27
|
+
``activate``/``deactivate`` (including the ``+``/``-`` arrow-decoration
|
|
28
|
+
shorthand) and ``<br/>`` are tolerated and flattened (no visual effect).
|
|
29
|
+
``loop``/``alt``/``opt``/``par``/``critical``/``break``/``rect``/``box``
|
|
30
|
+
open a horizontal separator band (with ``else``/``and``/``option`` as a
|
|
31
|
+
mid-band separator inside one); a stray separator/``end`` with no open
|
|
32
|
+
block, or any other unrecognized line, degrades to a plain text line
|
|
33
|
+
rather than raising.
|
|
34
|
+
|
|
35
|
+
Known degradations (by design, not bugs)
|
|
36
|
+
-----------------------------------------
|
|
37
|
+
- A ``Note left of`` the first participant, or block/plain labels wider
|
|
38
|
+
than the diagram, may overflow the canvas to the left/right; overflow
|
|
39
|
+
is acceptable (matching the existing flowchart renderer's contract),
|
|
40
|
+
never corruption or a crash.
|
|
41
|
+
- ``Note over``/``left of``/``right of`` with more than one participant
|
|
42
|
+
listed uses the first-and-last (or first, for left/right of) named
|
|
43
|
+
participant only — mirrors the simplification already used by the
|
|
44
|
+
Go-backed renderer's ``Note`` preprocessing.
|
|
45
|
+
- Participants are never created/destroyed mid-diagram; every lifeline
|
|
46
|
+
spans the full height of the diagram.
|
|
47
|
+
- Column/row placement is character-index-based, not visual-width-based
|
|
48
|
+
(``_Row`` indexes by ``len()``, not ``visual_len()``); wide (CJK/full-width)
|
|
49
|
+
participant labels or message text will misalign columns. This mirrors
|
|
50
|
+
the pre-existing ``wrap_text()`` CJK limitation elsewhere in termrender
|
|
51
|
+
(see the root CLAUDE.md); fixing it here requires the same visual-width
|
|
52
|
+
rework, out of scope for this renderer alone.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import re
|
|
58
|
+
from dataclasses import dataclass
|
|
59
|
+
|
|
60
|
+
from termrender.style import visual_center, visual_len
|
|
61
|
+
|
|
62
|
+
__all__ = ["render_sequence", "SequenceDiagramError"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SequenceDiagramError(Exception):
|
|
66
|
+
"""Raised when source cannot be parsed as a mermaid sequence diagram."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
# Parsed event model
|
|
71
|
+
# --------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Participant:
|
|
76
|
+
id: str
|
|
77
|
+
label: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Arrow:
|
|
82
|
+
source: str
|
|
83
|
+
target: str
|
|
84
|
+
dashed: bool
|
|
85
|
+
head_kind: str # "filled" | "open" | "lost" | "async"
|
|
86
|
+
text: str
|
|
87
|
+
number: int | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class NoteEvent:
|
|
92
|
+
participants: list[str]
|
|
93
|
+
position: str # "over" | "left" | "right"
|
|
94
|
+
text: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class BlockBoundary:
|
|
99
|
+
kind: str # "start" | "sep" | "end"
|
|
100
|
+
label: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class PlainLabel:
|
|
105
|
+
text: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
Event = Arrow | NoteEvent | BlockBoundary | PlainLabel
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --------------------------------------------------------------------------
|
|
112
|
+
# Grammar
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
_COMMENT_RE = re.compile(r"^%%")
|
|
116
|
+
_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
|
117
|
+
_AUTONUMBER_RE = re.compile(r"^autonumber(?:\s+(\d+))?(?:\s+(\d+))?\s*$", re.IGNORECASE)
|
|
118
|
+
_ACTIVATE_RE = re.compile(r"^(?:activate|deactivate)\s+\S+\s*$", re.IGNORECASE)
|
|
119
|
+
_PARTICIPANT_RE = re.compile(
|
|
120
|
+
r"^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?\s*$", re.IGNORECASE
|
|
121
|
+
)
|
|
122
|
+
_NOTE_RE = re.compile(
|
|
123
|
+
r"^[Nn]ote\s+(over|left\s+of|right\s+of)\s+([^:]+?)\s*:\s*(.*)$"
|
|
124
|
+
)
|
|
125
|
+
_BLOCK_START_RE = re.compile(
|
|
126
|
+
r"^(loop|alt|opt|par|critical|break|rect|box)\b\s*(.*)$", re.IGNORECASE
|
|
127
|
+
)
|
|
128
|
+
_BLOCK_SEP_RE = re.compile(r"^(else|and|option)\b\s*(.*)$", re.IGNORECASE)
|
|
129
|
+
_BLOCK_END_RE = re.compile(r"^end\s*$", re.IGNORECASE)
|
|
130
|
+
# Arrow forms: "-{1,2}" dash count marks solid vs dashed; the marker after it
|
|
131
|
+
# picks the arrowhead. Handles all 8 combinations: ->, -->, ->>, -->>, -x,
|
|
132
|
+
# --x, -), --). An optional +/- right after the arrow (activate/deactivate
|
|
133
|
+
# shorthand) is captured and discarded.
|
|
134
|
+
#
|
|
135
|
+
# Identifiers deliberately exclude '-' (and ':', '+', whitespace): dash is
|
|
136
|
+
# reserved for the arrow itself, so an id token is the maximal run up to the
|
|
137
|
+
# first dash. Without this exclusion, non-mermaid dash punctuation next to a
|
|
138
|
+
# real arrow char (e.g. "A-.->B", a flowchart-only dotted arrow with no
|
|
139
|
+
# sequence-diagram equivalent) gets silently absorbed into the id instead of
|
|
140
|
+
# failing to match and degrading to a plain line.
|
|
141
|
+
_ID = r"[^\s:+-]+"
|
|
142
|
+
_ARROW_RE = re.compile(
|
|
143
|
+
rf"^({_ID})\s*(-{{1,2}})(>>|>|x|\))\s*([+-]?)({_ID})\s*:\s*(.*)$"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
_HEAD_KIND = {">>": "filled", ">": "open", "x": "lost", ")": "async"}
|
|
147
|
+
_HEAD_GLYPH = {
|
|
148
|
+
"filled": {"right": ">", "left": "<"},
|
|
149
|
+
"open": {"right": "\u203a", "left": "\u2039"}, # › ‹
|
|
150
|
+
"lost": {"right": "\u2717", "left": "\u2717"}, # ✗
|
|
151
|
+
"async": {"right": ")", "left": "("},
|
|
152
|
+
}
|
|
153
|
+
_LINE_CHAR = {False: "\u2500", True: "\u254c"} # solid ─ / dashed ╌
|
|
154
|
+
|
|
155
|
+
_MIN_ARROW_GAP = 6
|
|
156
|
+
_MIN_SELF_GAP = 6
|
|
157
|
+
_BOX_PAD = 3
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _flatten(text: str) -> str:
|
|
161
|
+
return _BR_RE.sub(" / ", text) if text else ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _format_label(ev: Arrow) -> str:
|
|
165
|
+
text = _flatten(ev.text)
|
|
166
|
+
if ev.number is not None:
|
|
167
|
+
return f"{ev.number}: {text}" if text else f"{ev.number}:"
|
|
168
|
+
return text
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _note_position(raw: str) -> str:
|
|
172
|
+
low = raw.lower()
|
|
173
|
+
if low == "over":
|
|
174
|
+
return "over"
|
|
175
|
+
return "left" if "left" in low else "right"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse(lines: list[str]) -> tuple[list[Participant], list[Event]]:
|
|
179
|
+
participants: dict[str, Participant] = {}
|
|
180
|
+
events: list[Event] = []
|
|
181
|
+
stack: list[str] = []
|
|
182
|
+
seen_header = False
|
|
183
|
+
autonumber_active = False
|
|
184
|
+
autonumber_next = 1
|
|
185
|
+
autonumber_step = 1
|
|
186
|
+
|
|
187
|
+
def register(pid: str) -> None:
|
|
188
|
+
if pid not in participants:
|
|
189
|
+
participants[pid] = Participant(id=pid, label=pid)
|
|
190
|
+
|
|
191
|
+
for raw_line in lines:
|
|
192
|
+
line = raw_line.strip()
|
|
193
|
+
if not line:
|
|
194
|
+
continue
|
|
195
|
+
if not seen_header:
|
|
196
|
+
if line.lower().startswith("sequencediagram"):
|
|
197
|
+
seen_header = True
|
|
198
|
+
continue
|
|
199
|
+
if _COMMENT_RE.match(line):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
m = _AUTONUMBER_RE.match(line)
|
|
203
|
+
if m:
|
|
204
|
+
autonumber_active = True
|
|
205
|
+
if m.group(1):
|
|
206
|
+
autonumber_next = int(m.group(1))
|
|
207
|
+
if m.group(2):
|
|
208
|
+
autonumber_step = int(m.group(2))
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if _ACTIVATE_RE.match(line):
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
m = _PARTICIPANT_RE.match(line)
|
|
215
|
+
if m:
|
|
216
|
+
pid, alias = m.group(1), m.group(2)
|
|
217
|
+
label = alias.strip() if alias else pid
|
|
218
|
+
if pid in participants:
|
|
219
|
+
if alias:
|
|
220
|
+
participants[pid].label = label
|
|
221
|
+
else:
|
|
222
|
+
participants[pid] = Participant(id=pid, label=label)
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
m = _NOTE_RE.match(line)
|
|
226
|
+
if m:
|
|
227
|
+
position_raw, who, text = m.group(1), m.group(2), m.group(3)
|
|
228
|
+
who_ids = [w.strip() for w in who.split(",") if w.strip()]
|
|
229
|
+
for w in who_ids:
|
|
230
|
+
register(w)
|
|
231
|
+
events.append(
|
|
232
|
+
NoteEvent(
|
|
233
|
+
participants=who_ids,
|
|
234
|
+
position=_note_position(position_raw),
|
|
235
|
+
text=_flatten(text),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
m = _BLOCK_START_RE.match(line)
|
|
241
|
+
if m:
|
|
242
|
+
keyword, rest = m.group(1), m.group(2).strip()
|
|
243
|
+
label = f"{keyword} {rest}".strip() if rest else keyword
|
|
244
|
+
stack.append(label)
|
|
245
|
+
events.append(BlockBoundary(kind="start", label=label))
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
m = _BLOCK_SEP_RE.match(line)
|
|
249
|
+
if m:
|
|
250
|
+
keyword, rest = m.group(1), m.group(2).strip()
|
|
251
|
+
label = f"{keyword} {rest}".strip() if rest else keyword
|
|
252
|
+
if stack:
|
|
253
|
+
events.append(BlockBoundary(kind="sep", label=label))
|
|
254
|
+
else:
|
|
255
|
+
events.append(PlainLabel(text=_flatten(line)))
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
if _BLOCK_END_RE.match(line):
|
|
259
|
+
if stack:
|
|
260
|
+
stack.pop()
|
|
261
|
+
events.append(BlockBoundary(kind="end", label=""))
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
m = _ARROW_RE.match(line)
|
|
265
|
+
if m:
|
|
266
|
+
source, dashes, marker, _decor, target, text = m.groups()
|
|
267
|
+
register(source)
|
|
268
|
+
register(target)
|
|
269
|
+
number = None
|
|
270
|
+
if autonumber_active:
|
|
271
|
+
number = autonumber_next
|
|
272
|
+
autonumber_next += autonumber_step
|
|
273
|
+
events.append(
|
|
274
|
+
Arrow(
|
|
275
|
+
source=source,
|
|
276
|
+
target=target,
|
|
277
|
+
dashed=len(dashes) == 2,
|
|
278
|
+
head_kind=_HEAD_KIND[marker],
|
|
279
|
+
text=text.strip(),
|
|
280
|
+
number=number,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
events.append(PlainLabel(text=_flatten(line)))
|
|
286
|
+
|
|
287
|
+
return list(participants.values()), events
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# --------------------------------------------------------------------------
|
|
291
|
+
# Column layout
|
|
292
|
+
# --------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _layout_columns(
|
|
296
|
+
participants: list[Participant], events: list[Event]
|
|
297
|
+
) -> tuple[list[int], list[int]]:
|
|
298
|
+
n = len(participants)
|
|
299
|
+
if n == 0:
|
|
300
|
+
return [], []
|
|
301
|
+
|
|
302
|
+
id_index = {p.id: i for i, p in enumerate(participants)}
|
|
303
|
+
box_widths = [visual_len(p.label) + 4 for p in participants]
|
|
304
|
+
gaps = [
|
|
305
|
+
box_widths[i] // 2 + box_widths[i + 1] // 2 + _BOX_PAD for i in range(n - 1)
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
for ev in events:
|
|
309
|
+
if not isinstance(ev, Arrow):
|
|
310
|
+
continue
|
|
311
|
+
if ev.source not in id_index or ev.target not in id_index:
|
|
312
|
+
continue
|
|
313
|
+
si, ti = id_index[ev.source], id_index[ev.target]
|
|
314
|
+
label_text = _format_label(ev)
|
|
315
|
+
if si == ti:
|
|
316
|
+
if si < n - 1:
|
|
317
|
+
required = max(visual_len(label_text) + 4, _MIN_SELF_GAP)
|
|
318
|
+
gaps[si] = max(gaps[si], required)
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
lo, hi = (si, ti) if si < ti else (ti, si)
|
|
322
|
+
required = max(visual_len(label_text) + 2, _MIN_ARROW_GAP)
|
|
323
|
+
if hi == lo + 1:
|
|
324
|
+
gaps[lo] = max(gaps[lo], required)
|
|
325
|
+
else:
|
|
326
|
+
span = range(lo, hi)
|
|
327
|
+
count = hi - lo
|
|
328
|
+
current = sum(gaps[i] for i in span)
|
|
329
|
+
if current < required:
|
|
330
|
+
deficit = required - current
|
|
331
|
+
share, rem = divmod(deficit, count)
|
|
332
|
+
for j, i in enumerate(span):
|
|
333
|
+
gaps[i] += share + (1 if j < rem else 0)
|
|
334
|
+
|
|
335
|
+
centers = [0] * n
|
|
336
|
+
centers[0] = box_widths[0] // 2
|
|
337
|
+
for i in range(1, n):
|
|
338
|
+
centers[i] = centers[i - 1] + gaps[i - 1]
|
|
339
|
+
return centers, box_widths
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --------------------------------------------------------------------------
|
|
343
|
+
# Row rendering
|
|
344
|
+
# --------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _Row:
|
|
348
|
+
"""A growable line of characters, addressed by absolute column."""
|
|
349
|
+
|
|
350
|
+
__slots__ = ("chars",)
|
|
351
|
+
|
|
352
|
+
def __init__(self, width: int) -> None:
|
|
353
|
+
self.chars: list[str] = [" "] * max(width, 0)
|
|
354
|
+
|
|
355
|
+
def set(self, x: int, s: str) -> None:
|
|
356
|
+
if x < 0 or not s:
|
|
357
|
+
return
|
|
358
|
+
end = x + len(s)
|
|
359
|
+
if end > len(self.chars):
|
|
360
|
+
self.chars.extend([" "] * (end - len(self.chars)))
|
|
361
|
+
for i, ch in enumerate(s):
|
|
362
|
+
self.chars[x + i] = ch
|
|
363
|
+
|
|
364
|
+
def to_string(self) -> str:
|
|
365
|
+
return "".join(self.chars).rstrip()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _lifeline_row(diagram_width: int, centers: list[int]) -> _Row:
|
|
369
|
+
row = _Row(diagram_width)
|
|
370
|
+
for x in centers:
|
|
371
|
+
row.set(x, "\u2502") # │
|
|
372
|
+
return row
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _participant_box(width: int, label: str) -> tuple[str, str, str]:
|
|
376
|
+
inner = max(width - 2, 0)
|
|
377
|
+
top = "\u250c" + "\u2500" * inner + "\u2510" # ┌───┐
|
|
378
|
+
mid = "\u2502" + visual_center(label, inner) + "\u2502"
|
|
379
|
+
bot = "\u2514" + "\u2500" * inner + "\u2518" # └───┘
|
|
380
|
+
return top, mid, bot
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _header_rows(
|
|
384
|
+
participants: list[Participant],
|
|
385
|
+
box_lefts: list[int],
|
|
386
|
+
box_widths: list[int],
|
|
387
|
+
diagram_width: int,
|
|
388
|
+
) -> tuple[str, str, str]:
|
|
389
|
+
top_row = _Row(diagram_width)
|
|
390
|
+
mid_row = _Row(diagram_width)
|
|
391
|
+
bot_row = _Row(diagram_width)
|
|
392
|
+
for p, left, w in zip(participants, box_lefts, box_widths):
|
|
393
|
+
top, mid, bot = _participant_box(w, p.label)
|
|
394
|
+
top_row.set(left, top)
|
|
395
|
+
mid_row.set(left, mid)
|
|
396
|
+
bot_row.set(left, bot)
|
|
397
|
+
return top_row.to_string(), mid_row.to_string(), bot_row.to_string()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _line_row(
|
|
401
|
+
diagram_width: int,
|
|
402
|
+
centers: list[int],
|
|
403
|
+
lo: int,
|
|
404
|
+
hi: int,
|
|
405
|
+
rightwards: bool,
|
|
406
|
+
dashed: bool,
|
|
407
|
+
head_kind: str,
|
|
408
|
+
) -> str:
|
|
409
|
+
row = _lifeline_row(diagram_width, centers)
|
|
410
|
+
line_char = _LINE_CHAR[dashed]
|
|
411
|
+
x0, x1 = centers[lo], centers[hi]
|
|
412
|
+
for x in range(x0, x1 + 1):
|
|
413
|
+
row.set(x, line_char)
|
|
414
|
+
glyphs = _HEAD_GLYPH[head_kind]
|
|
415
|
+
if rightwards:
|
|
416
|
+
row.set(x1, glyphs["right"])
|
|
417
|
+
else:
|
|
418
|
+
row.set(x0, glyphs["left"])
|
|
419
|
+
return row.to_string()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _label_row(
|
|
423
|
+
diagram_width: int, centers: list[int], lo: int, hi: int, label_text: str
|
|
424
|
+
) -> str | None:
|
|
425
|
+
if not label_text:
|
|
426
|
+
return None
|
|
427
|
+
row = _lifeline_row(diagram_width, centers)
|
|
428
|
+
span_width = centers[hi] - centers[lo] + 1
|
|
429
|
+
row.set(centers[lo], visual_center(label_text, span_width))
|
|
430
|
+
return row.to_string()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _self_loop_rows(
|
|
434
|
+
diagram_width: int,
|
|
435
|
+
centers: list[int],
|
|
436
|
+
idx_s: int,
|
|
437
|
+
label_text: str,
|
|
438
|
+
dashed: bool,
|
|
439
|
+
head_kind: str,
|
|
440
|
+
) -> list[str]:
|
|
441
|
+
x = centers[idx_s]
|
|
442
|
+
line_char = _LINE_CHAR[dashed]
|
|
443
|
+
glyphs = _HEAD_GLYPH[head_kind]
|
|
444
|
+
|
|
445
|
+
out_row = _lifeline_row(diagram_width, centers)
|
|
446
|
+
out_row.set(x, line_char + "\u256e") # ╮ (round down-right corner)
|
|
447
|
+
|
|
448
|
+
mid_row = _lifeline_row(diagram_width, centers)
|
|
449
|
+
label_part = ("\u2502 " + label_text) if label_text else "\u2502"
|
|
450
|
+
mid_row.set(x + 1, label_part)
|
|
451
|
+
|
|
452
|
+
in_row = _lifeline_row(diagram_width, centers)
|
|
453
|
+
in_row.set(x, glyphs["left"] + "\u256f") # ╯ (round up-left corner)
|
|
454
|
+
|
|
455
|
+
return [out_row.to_string(), mid_row.to_string(), in_row.to_string()]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _note_box_rows(
|
|
459
|
+
diagram_width: int, centers: list[int], box_left: int, box_width: int, text: str
|
|
460
|
+
) -> list[str]:
|
|
461
|
+
top, mid, bot = _participant_box(box_width, text)
|
|
462
|
+
rows = []
|
|
463
|
+
for content in (top, mid, bot):
|
|
464
|
+
row = _lifeline_row(diagram_width, centers)
|
|
465
|
+
row.set(max(box_left, 0), content)
|
|
466
|
+
rows.append(row.to_string())
|
|
467
|
+
return rows
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _band_row(diagram_width: int, kind: str, label: str) -> str:
|
|
471
|
+
if kind == "end":
|
|
472
|
+
fill = max(diagram_width - 2, 0)
|
|
473
|
+
return "\u2514" + "\u2500" * fill + "\u2518" # └───┘
|
|
474
|
+
left, right = ("\u250c", "\u2510") if kind == "start" else ("\u251c", "\u2524")
|
|
475
|
+
prefix = f"\u2500 {label} " if label else "\u2500 "
|
|
476
|
+
fill = max(diagram_width - 2 - visual_len(prefix), 0)
|
|
477
|
+
return left + prefix + "\u2500" * fill + right
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _plain_label_row(diagram_width: int, text: str) -> str:
|
|
481
|
+
row = _Row(diagram_width)
|
|
482
|
+
content = visual_center(text, diagram_width) if diagram_width > 0 else text
|
|
483
|
+
row.set(0, content)
|
|
484
|
+
return row.to_string()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _render_arrow(
|
|
488
|
+
ev: Arrow, centers: list[int], id_index: dict[str, int], diagram_width: int
|
|
489
|
+
) -> list[str]:
|
|
490
|
+
si, ti = id_index[ev.source], id_index[ev.target]
|
|
491
|
+
label_text = _format_label(ev)
|
|
492
|
+
|
|
493
|
+
if si == ti:
|
|
494
|
+
return _self_loop_rows(diagram_width, centers, si, label_text, ev.dashed, ev.head_kind)
|
|
495
|
+
|
|
496
|
+
lo, hi = (si, ti) if si < ti else (ti, si)
|
|
497
|
+
rightwards = si < ti
|
|
498
|
+
rows: list[str] = []
|
|
499
|
+
label_row = _label_row(diagram_width, centers, lo, hi, label_text)
|
|
500
|
+
if label_row is not None:
|
|
501
|
+
rows.append(label_row)
|
|
502
|
+
rows.append(_line_row(diagram_width, centers, lo, hi, rightwards, ev.dashed, ev.head_kind))
|
|
503
|
+
return rows
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _render_note(
|
|
507
|
+
ev: NoteEvent, centers: list[int], id_index: dict[str, int], diagram_width: int
|
|
508
|
+
) -> list[str]:
|
|
509
|
+
idxs = [id_index[p] for p in ev.participants if p in id_index]
|
|
510
|
+
if not idxs:
|
|
511
|
+
return [_plain_label_row(diagram_width, f"Note: {ev.text}")]
|
|
512
|
+
lo, hi = min(idxs), max(idxs)
|
|
513
|
+
text = ev.text
|
|
514
|
+
box_width = visual_len(text) + 4
|
|
515
|
+
|
|
516
|
+
if ev.position == "over":
|
|
517
|
+
span_center = (centers[lo] + centers[hi]) // 2
|
|
518
|
+
box_width = max(box_width, centers[hi] - centers[lo] + 4)
|
|
519
|
+
box_left = span_center - box_width // 2
|
|
520
|
+
elif ev.position == "left":
|
|
521
|
+
box_left = centers[lo] - 2 - box_width
|
|
522
|
+
else: # "right"
|
|
523
|
+
box_left = centers[lo] + 2
|
|
524
|
+
|
|
525
|
+
return _note_box_rows(diagram_width, centers, box_left, box_width, text)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _render_event(
|
|
529
|
+
ev: Event, centers: list[int], id_index: dict[str, int], diagram_width: int
|
|
530
|
+
) -> list[str]:
|
|
531
|
+
if isinstance(ev, Arrow):
|
|
532
|
+
return _render_arrow(ev, centers, id_index, diagram_width)
|
|
533
|
+
if isinstance(ev, NoteEvent):
|
|
534
|
+
return _render_note(ev, centers, id_index, diagram_width)
|
|
535
|
+
if isinstance(ev, BlockBoundary):
|
|
536
|
+
return [_band_row(diagram_width, ev.kind, ev.label)]
|
|
537
|
+
if isinstance(ev, PlainLabel):
|
|
538
|
+
return [_plain_label_row(diagram_width, ev.text)]
|
|
539
|
+
raise SequenceDiagramError(f"unrenderable event: {ev!r}")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# --------------------------------------------------------------------------
|
|
543
|
+
# Public entry point
|
|
544
|
+
# --------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def render_sequence(source: str, width: int) -> list[str]:
|
|
548
|
+
"""Render a mermaid ``sequenceDiagram`` source to ASCII lines.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
source: The mermaid fence body; its first non-blank line must
|
|
552
|
+
start with ``sequenceDiagram``.
|
|
553
|
+
width: Advisory terminal width. Sequence diagrams have an
|
|
554
|
+
intrinsic width driven by participant labels and message
|
|
555
|
+
text; this function does not wrap or truncate to fit —
|
|
556
|
+
overflow beyond ``width`` is acceptable, matching the
|
|
557
|
+
existing flowchart renderer's contract.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
Rendered lines: monochrome unicode box-drawing, no ANSI, no
|
|
561
|
+
significant trailing whitespace.
|
|
562
|
+
|
|
563
|
+
Raises:
|
|
564
|
+
SequenceDiagramError: If ``source`` is not a mermaid sequence
|
|
565
|
+
diagram at all (missing the ``sequenceDiagram`` header).
|
|
566
|
+
"""
|
|
567
|
+
del width # advisory only; diagrams have intrinsic width (see docstring)
|
|
568
|
+
|
|
569
|
+
lines = source.splitlines()
|
|
570
|
+
first = next((line.strip() for line in lines if line.strip()), "")
|
|
571
|
+
if not first.lower().startswith("sequencediagram"):
|
|
572
|
+
raise SequenceDiagramError(
|
|
573
|
+
"not a mermaid sequence diagram: source must start with 'sequenceDiagram'"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
participants, events = _parse(lines)
|
|
577
|
+
centers, box_widths = _layout_columns(participants, events)
|
|
578
|
+
box_lefts = [c - w // 2 for c, w in zip(centers, box_widths)]
|
|
579
|
+
diagram_width = (box_lefts[-1] + box_widths[-1]) if participants else 0
|
|
580
|
+
|
|
581
|
+
out: list[str] = []
|
|
582
|
+
header: tuple[str, str, str] | None = None
|
|
583
|
+
if participants:
|
|
584
|
+
header = _header_rows(participants, box_lefts, box_widths, diagram_width)
|
|
585
|
+
out.extend(header)
|
|
586
|
+
out.append(_lifeline_row(diagram_width, centers).to_string())
|
|
587
|
+
|
|
588
|
+
id_index = {p.id: i for i, p in enumerate(participants)}
|
|
589
|
+
for ev in events:
|
|
590
|
+
out.extend(_render_event(ev, centers, id_index, diagram_width))
|
|
591
|
+
if participants:
|
|
592
|
+
out.append(_lifeline_row(diagram_width, centers).to_string())
|
|
593
|
+
|
|
594
|
+
if header is not None:
|
|
595
|
+
out.extend(header)
|
|
596
|
+
|
|
597
|
+
return out
|