termrender 4.6.0__tar.gz → 4.6.2__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.6.0 → termrender-4.6.2}/CHANGELOG.md +64 -0
- {termrender-4.6.0 → termrender-4.6.2}/PKG-INFO +1 -1
- termrender-4.6.2/scripts/mermaid_flow_parity.py +168 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow.py +35 -11
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_layout.py +120 -54
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_parser.py +128 -59
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow.py +87 -2
- termrender-4.6.2/tests/test_mermaid_flow_corpus.py +448 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_layout.py +108 -18
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_parser.py +91 -0
- {termrender-4.6.0 → termrender-4.6.2}/.github/workflows/publish.yml +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/.gitignore +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/CLAUDE.md +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/LICENSE +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/README.md +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/design.json +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/pyproject.toml +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/requirements.json +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/__init__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/__main__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/blocks.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/emit.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/layout.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/parser.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/py.typed +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/code.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/table.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/text.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/src/termrender/style.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/__init__.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_charts.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_cli_contract.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_column_alignment.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_diff.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_inline_badge.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_linebreak.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_myst_gaps.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_stat.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_tasklist.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_timeline.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/tests/test_variable_colons.py +0 -0
- {termrender-4.6.0 → termrender-4.6.2}/uv.lock +0 -0
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.6.2 (2026-07-07)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **mermaid-flow**: Preserve edge labels, render self-loops, sanitize degraded echo
|
|
9
|
+
([`263100f`](https://github.com/crouton-labs/termrender/commit/263100f16e1a7f0044497f2dedd0fd10f0831626))
|
|
10
|
+
|
|
11
|
+
- Router now draws all edge polylines+arrowheads first, then all edge labels last (reserving each
|
|
12
|
+
label's cells, plus a margin), so a later edge's line can never silently erase an earlier edge's
|
|
13
|
+
label and two labels sharing a lane render with visible separation instead of concatenating. -
|
|
14
|
+
Rank-band gaps widen to fit an adjacent-rank edge's label in full (_rank_gap_overrides), fixing
|
|
15
|
+
short LR/forward-adjacent labeled edges (e.g. "hello") that were previously clipped to a couple of
|
|
16
|
+
characters. - Raw-echo degradation now strips any box-drawing/geometric glyph (\u2500-\u259f,
|
|
17
|
+
\u25a0-\u25ff) present in the malformed source itself, so a degraded echo can never be misdetected
|
|
18
|
+
as a successful render. - Hardened green-theatre tests: self-loop tests now assert real loop
|
|
19
|
+
geometry (arrowhead + glyphs past the box border) instead of "non-empty output"; the
|
|
20
|
+
edge-visibility test now inspects the guaranteed-clear inter-box gap instead of accepting
|
|
21
|
+
box-border glyphs; cycle/labeled-back-edge/direction/never-raises tests gained genuine
|
|
22
|
+
topology/degradation assertions in place of crash-only checks. - Updated the two corpus goldens
|
|
23
|
+
(pipe-label, inline-label) whose rendered height changed under the new label-aware gap widening.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## v4.6.1 (2026-07-07)
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
- **mermaid-flow**: Parse chained edges and bracket-aware tokenization
|
|
31
|
+
([`5040b96`](https://github.com/crouton-labs/termrender/commit/5040b967bbe75c70c6d03d57930742ba020e3333))
|
|
32
|
+
|
|
33
|
+
- Unify edge parsing behind one bracket-depth-aware connector scanner (_scan_connectors) shared with
|
|
34
|
+
& fan-out splitting and ; statement splitting, replacing the single-connector _EDGE_RE. -
|
|
35
|
+
CRITICAL: chained edge statements (A-->B-->C) now parse as a sequence of node groups separated by
|
|
36
|
+
connectors, emitting one edge per adjacent triple instead of dropping the tail after the first
|
|
37
|
+
connector. - MAJOR: connector-looking text inside [], (), {} labels (A[Go --> Fast]) is no longer
|
|
38
|
+
mistaken for an edge connector. - MINOR: semicolons inside labels (A[Check; validate]) no longer
|
|
39
|
+
split the statement.
|
|
40
|
+
|
|
41
|
+
Adds structural tests for 2/3-link chains, mixed-style chains, chain with inline label,
|
|
42
|
+
connector-in-label for all three connector families, and semicolon-in-label. Adds a chained-form
|
|
43
|
+
golden for the labeled back-edge cycle proving identical render to the separate-line form.
|
|
44
|
+
|
|
45
|
+
### Testing
|
|
46
|
+
|
|
47
|
+
- Add flowchart golden corpus and go-binary parity harness
|
|
48
|
+
([`2a5a00a`](https://github.com/crouton-labs/termrender/commit/2a5a00aba18195eac987bc8c202a80724d6e43c9))
|
|
49
|
+
|
|
50
|
+
- tests/test_mermaid_flow_corpus.py: 26 golden-output tests pinning exact render_flowchart() lines
|
|
51
|
+
across all 9 node shapes, every edge style (solid/dotted/thick/headless/bidirectional), both label
|
|
52
|
+
forms, & fan-out, a multi-parent DAG, a labeled-back-edge cycle (the case the vendored Go binary
|
|
53
|
+
panics/mis-parses on), LR vs TD, subgraph + nested subgraph, a long label vs a tight width budget,
|
|
54
|
+
and the two degradation paths. - scripts/mermaid_flow_parity.py: standalone parity harness (not a
|
|
55
|
+
pytest dependency) that invokes the vendored Go binary the way termrender's real dispatch path
|
|
56
|
+
does (preprocess_mermaid_for_ascii + fix_mermaid_encoding) and the native renderer side by side
|
|
57
|
+
across 14 representative inputs, writing the comparison to the orchestrator's shared context dir.
|
|
58
|
+
Confirms parity-or-better plus two Go failure modes the native renderer avoids: a self-loop panic
|
|
59
|
+
(index out of range) and labeled-back-edge mis-parsing (label text swallowed into a phantom node
|
|
60
|
+
instead of a back-edge). - fix: mermaid_flow.py's module docstring described a stale prior phase
|
|
61
|
+
("every shape renders as a plain rectangle", "subgraphs parse but aren't drawn as frames") — both
|
|
62
|
+
are false now; corrected to describe actual current behavior (distinct shape borders, framed
|
|
63
|
+
subgraphs with contiguous-membership flattening).
|
|
64
|
+
|
|
65
|
+
463 tests green (437 pre-existing + 26 new corpus).
|
|
66
|
+
|
|
67
|
+
|
|
4
68
|
## v4.6.0 (2026-07-06)
|
|
5
69
|
|
|
6
70
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.6.
|
|
3
|
+
Version: 4.6.2
|
|
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
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Standalone parity harness: native flowchart renderer vs. the vendored Go
|
|
3
|
+
binary, for a curated set of representative mermaid flowchart sources.
|
|
4
|
+
|
|
5
|
+
Not a pytest test and not imported by the test suite — the corpus tests must
|
|
6
|
+
not depend on the vendored ``mermaid-ascii`` binary being present on disk.
|
|
7
|
+
Run this once (locally, wherever the vendored binary for the host platform
|
|
8
|
+
exists) to (re)generate the side-by-side comparison artifact:
|
|
9
|
+
|
|
10
|
+
.venv/bin/python scripts/mermaid_flow_parity.py
|
|
11
|
+
|
|
12
|
+
Invokes the Go binary exactly the way termrender's real dispatch path does
|
|
13
|
+
(``mermaid.py::_render_via_binary``): run ``preprocess_mermaid_for_ascii``
|
|
14
|
+
(flowchart shape normalization) on the source, shell out to
|
|
15
|
+
``mermaid_ascii_bin()`` with ``-f - -y 1``, and undo its Latin-1
|
|
16
|
+
double-encoding via ``fix_mermaid_encoding`` — so the comparison is fair
|
|
17
|
+
(same preprocessing termrender actually applies before falling back to the
|
|
18
|
+
binary today).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
28
|
+
|
|
29
|
+
from termrender._mermaid_bin import mermaid_ascii_bin
|
|
30
|
+
from termrender.renderers.mermaid import fix_mermaid_encoding, preprocess_mermaid_for_ascii
|
|
31
|
+
from termrender.renderers.mermaid_flow import render_flowchart
|
|
32
|
+
|
|
33
|
+
OUTPUT_PATH = Path(
|
|
34
|
+
"/Users/silasrhyneer/.crouter/canvas/nodes/mr9swads-0f62d05c/context/flow-parity-sample.md"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
WIDTH = 60
|
|
38
|
+
|
|
39
|
+
CASES: list[tuple[str, str]] = [
|
|
40
|
+
(
|
|
41
|
+
"Simple forward DAG",
|
|
42
|
+
"graph TD\nA-->B\nA-->C\n",
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"Multi-parent DAG",
|
|
46
|
+
"graph TD\nA-->C\nB-->C\n",
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
"Headless edge (`---`)",
|
|
50
|
+
"graph TD\nA---B\n",
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
"Pipe-labeled edge",
|
|
54
|
+
"graph TD\nA-->|hi|B\n",
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
"Inline-labeled edge",
|
|
58
|
+
"graph TD\nA -- go now --> B\n",
|
|
59
|
+
),
|
|
60
|
+
(
|
|
61
|
+
"`&` fan-out",
|
|
62
|
+
"graph TD\nA & B --> C & D\n",
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"Non-rect shapes (diamond, circle, cylinder)",
|
|
66
|
+
"graph TD\nA{Decision}-->B((Circle))\nB-->C[(DB)]\n",
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"LR direction",
|
|
70
|
+
"graph LR\nA-->B\nA-->C\n",
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"Subgraph frame",
|
|
74
|
+
"graph TD\nsubgraph zone[Zone]\nA-->B\nend\n",
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"Plain 3-cycle (no label)",
|
|
78
|
+
"graph TD\nA-->B\nB-->C\nC-->A\n",
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"Self-loop (Go binary PANICS: index out of range in drawLine)",
|
|
82
|
+
"graph TD\nA-->A\n",
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"Cycle with a LABELED back-edge (Go binary mis-parses the label as "
|
|
86
|
+
"a phantom node — wrong topology; this is the case the requirements "
|
|
87
|
+
"doc calls out as the one the Go binary cannot handle)",
|
|
88
|
+
"graph TD\nA-->B\nB-->C\nC-->|retry|A\n",
|
|
89
|
+
),
|
|
90
|
+
(
|
|
91
|
+
"Same labeled-back-edge cycle, spaced arrows (Go binary still "
|
|
92
|
+
"fabricates a phantom 4th node '|retry| A' instead of a back-edge "
|
|
93
|
+
"into A — the label-parsing bug is not a whitespace quirk)",
|
|
94
|
+
"graph TD\nA --> B\nB --> C\nC --> |retry| A\n",
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"Simple forward DAG, spaced arrows (Go binary's own preferred "
|
|
98
|
+
"form — included so the unspaced cases above are judged fairly "
|
|
99
|
+
"against Go's best case, not just its worst case)",
|
|
100
|
+
"graph TD\nA --> B\nA --> C\n",
|
|
101
|
+
),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_via_go_binary(source: str) -> tuple[str, bool]:
|
|
106
|
+
"""Return (rendered_text, panicked). Mirrors mermaid.py::_render_via_binary
|
|
107
|
+
but surfaces panic/failure explicitly instead of silently echoing source
|
|
108
|
+
(the harness wants to *show* the failure, not hide it)."""
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[mermaid_ascii_bin(), "-f", "-", "-y", "1"],
|
|
112
|
+
input=preprocess_mermaid_for_ascii(source),
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
timeout=30,
|
|
116
|
+
)
|
|
117
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
118
|
+
return f"[harness error invoking binary: {exc}]", True
|
|
119
|
+
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
stderr_tail = "\n".join(result.stderr.strip().splitlines()[:4])
|
|
122
|
+
return f"PANIC (exit {result.returncode}):\n{stderr_tail}", True
|
|
123
|
+
|
|
124
|
+
return fix_mermaid_encoding(result.stdout), False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
sections: list[str] = [
|
|
129
|
+
"# Native flowchart renderer vs. Go `mermaid-ascii` — parity sample",
|
|
130
|
+
"",
|
|
131
|
+
"Generated by `scripts/mermaid_flow_parity.py`. The Go binary is",
|
|
132
|
+
"invoked exactly the way termrender's real dispatch path does today",
|
|
133
|
+
"(`mermaid.py::_render_via_binary`): flowchart shape normalization via",
|
|
134
|
+
"`preprocess_mermaid_for_ascii`, then `mermaid-ascii -f - -y 1`, then",
|
|
135
|
+
"`fix_mermaid_encoding` on the output.",
|
|
136
|
+
"",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
for title, source in CASES:
|
|
140
|
+
go_text, panicked = render_via_go_binary(source)
|
|
141
|
+
our_lines = render_flowchart(source, WIDTH)
|
|
142
|
+
our_text = "\n".join(our_lines)
|
|
143
|
+
|
|
144
|
+
sections.append(f"## {title}")
|
|
145
|
+
sections.append("")
|
|
146
|
+
sections.append("Mermaid source:")
|
|
147
|
+
sections.append("```")
|
|
148
|
+
sections.append(source.rstrip("\n"))
|
|
149
|
+
sections.append("```")
|
|
150
|
+
sections.append("")
|
|
151
|
+
sections.append(f"Go binary output{' (PANIC/failure)' if panicked else ''}:")
|
|
152
|
+
sections.append("```")
|
|
153
|
+
sections.append(go_text if go_text.strip() else "(empty)")
|
|
154
|
+
sections.append("```")
|
|
155
|
+
sections.append("")
|
|
156
|
+
sections.append("Native `render_flowchart` output:")
|
|
157
|
+
sections.append("```")
|
|
158
|
+
sections.append(our_text if our_text.strip() else "(empty)")
|
|
159
|
+
sections.append("```")
|
|
160
|
+
sections.append("")
|
|
161
|
+
|
|
162
|
+
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
OUTPUT_PATH.write_text("\n".join(sections) + "\n")
|
|
164
|
+
print(f"wrote {OUTPUT_PATH}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
main()
|
|
@@ -25,19 +25,25 @@ Grammar supported
|
|
|
25
25
|
Header: ``graph``/``flowchart`` + direction (``TD``/``TB``/``LR``/``RL``/
|
|
26
26
|
``BT``). Node shapes: ``[rect]``, ``(round)``, ``([stadium])``,
|
|
27
27
|
``[(cylinder)]``, ``((circle))``, ``{diamond}``, ``{{hexagon}}``,
|
|
28
|
-
``[[subroutine]]``, ``[/parallelogram/]`` —
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
``
|
|
28
|
+
``[[subroutine]]``, ``[/parallelogram/]`` — each renders with a distinct
|
|
29
|
+
shape-specific border (diamond/parallelogram slant tapers, round/stadium/
|
|
30
|
+
circle rounded corners, a cylinder's rounded cap over a square base, a
|
|
31
|
+
hexagon's cut corners, a subroutine's double-bar sides); only a shape whose
|
|
32
|
+
box is too small for its own drawer falls back to the plain rectangle
|
|
33
|
+
border. Edges: ``-->``, ``---``, ``-.->``, ``==>`` (and bidirectional
|
|
34
|
+
forms), with ``|label|`` and inline ``A -- text --> B`` labels, and ``&``
|
|
35
|
+
fan-out. ``subgraph ... end`` blocks (including nested subgraphs) render as
|
|
36
|
+
enclosing frames with a left-anchored title when their members place
|
|
37
|
+
contiguously enough for a clean rect; otherwise a block flattens (no frame,
|
|
38
|
+
members still placed and drawn) rather than draw a frame that would
|
|
39
|
+
visually claim a non-member node. ``class``/``classDef``/``style``/
|
|
40
|
+
``click``/``linkStyle`` lines are ignored.
|
|
35
41
|
|
|
36
42
|
Known degradations (by design, not bugs)
|
|
37
43
|
-----------------------------------------
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
- A subgraph whose members aren't placed contiguously enough for a clean
|
|
45
|
+
enclosing rect flattens (drops its frame and title) rather than draw a
|
|
46
|
+
frame that visually claims a non-member node.
|
|
41
47
|
- See ``mermaid_flow_layout.py``'s module docstring for the edge-routing
|
|
42
48
|
degradations (dense-graph crossings, label-lane overlap, CJK wrapping,
|
|
43
49
|
minimum-box-size self-loop cosmetics).
|
|
@@ -48,11 +54,21 @@ Known degradations (by design, not bugs)
|
|
|
48
54
|
|
|
49
55
|
from __future__ import annotations
|
|
50
56
|
|
|
57
|
+
import re
|
|
58
|
+
|
|
51
59
|
from termrender.renderers.mermaid_flow_layout import layout_flowgraph
|
|
52
60
|
from termrender.renderers.mermaid_flow_parser import FlowchartError, parse
|
|
53
61
|
|
|
54
62
|
__all__ = ["render_flowchart"]
|
|
55
63
|
|
|
64
|
+
# Same ranges the downstream attach viewer uses to detect render *success*
|
|
65
|
+
# (see the module docstring's "Degradation contract"). The raw-echo path
|
|
66
|
+
# must never contain one of these, even when the malformed/degenerate
|
|
67
|
+
# source itself happens to contain a literal box-drawing or geometric
|
|
68
|
+
# glyph (e.g. hand-typed "\u250c" in otherwise-non-flowchart text) — otherwise a
|
|
69
|
+
# failed render could be misdetected as a successful one.
|
|
70
|
+
_GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
|
|
71
|
+
|
|
56
72
|
|
|
57
73
|
def render_flowchart(source: str, width: int) -> list[str]:
|
|
58
74
|
"""Render a mermaid ``graph``/``flowchart`` source to unicode lines.
|
|
@@ -107,4 +123,12 @@ def render_flowchart(source: str, width: int) -> list[str]:
|
|
|
107
123
|
|
|
108
124
|
|
|
109
125
|
def _raw_echo(source: str) -> list[str]:
|
|
110
|
-
|
|
126
|
+
"""Plain echo of ``source``'s lines, with any box-drawing/geometric
|
|
127
|
+
glyph (the same ``\\u2500-\\u259f``/``\\u25a0-\\u25ff`` ranges the
|
|
128
|
+
downstream viewer checks for) replaced by ``?`` — the degradation
|
|
129
|
+
contract requires NO such glyph survive, even one present verbatim in
|
|
130
|
+
malformed/degenerate source, not just glyphs this renderer would have
|
|
131
|
+
drawn itself."""
|
|
132
|
+
return [
|
|
133
|
+
_GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()
|
|
134
|
+
]
|
|
@@ -721,6 +721,38 @@ def _box_dims(label: str, shape: NodeShape = NodeShape.RECT) -> tuple[int, int]:
|
|
|
721
721
|
_GAP_X = 3 # minimum border-to-border gap between boxes in one rank/band
|
|
722
722
|
_ROW_GAP = 2 # minimum gap between rank bands
|
|
723
723
|
_COMPONENT_GUTTER = 4 # gap between independently-laid-out components
|
|
724
|
+
_LABEL_GAP_PAD = 1 # cells of breathing room either side of a label's own text
|
|
725
|
+
# when it forces a rank-band gap wider than _ROW_GAP.
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _rank_gap_overrides(
|
|
729
|
+
edges: list[FlowEdge], rank_of: dict[str, int]
|
|
730
|
+
) -> dict[int, int]:
|
|
731
|
+
"""Minimum inter-rank-band gap keyed by the *lower* rank of each
|
|
732
|
+
adjacent-rank transition, widened past ``_ROW_GAP`` wherever a labeled
|
|
733
|
+
edge directly connects that transition's two ranks. A forward edge
|
|
734
|
+
between adjacent ranks routes as a single straight run (or a Z path
|
|
735
|
+
that still crosses the same inter-rank band) whose only clear space is
|
|
736
|
+
this gap — at the base ``_ROW_GAP`` a label wider than a couple of
|
|
737
|
+
cells has nowhere to go but onto the boxes it connects, which is
|
|
738
|
+
exactly the short LR/adjacent-rank clipped-label bug this closes.
|
|
739
|
+
Non-adjacent-rank edges (back-edges, multi-rank spans) don't constrain
|
|
740
|
+
the gap here — they route through their own side lane, not this band.
|
|
741
|
+
"""
|
|
742
|
+
overrides: dict[int, int] = {}
|
|
743
|
+
for e in edges:
|
|
744
|
+
if e.src == e.dst or not e.label:
|
|
745
|
+
continue
|
|
746
|
+
r_src = rank_of.get(e.src)
|
|
747
|
+
r_dst = rank_of.get(e.dst)
|
|
748
|
+
if r_src is None or r_dst is None:
|
|
749
|
+
continue
|
|
750
|
+
lo_r, hi_r = (r_src, r_dst) if r_src <= r_dst else (r_dst, r_src)
|
|
751
|
+
if hi_r - lo_r != 1:
|
|
752
|
+
continue
|
|
753
|
+
needed = visual_len(e.label) + 2 * _LABEL_GAP_PAD
|
|
754
|
+
overrides[lo_r] = max(overrides.get(lo_r, _ROW_GAP), needed)
|
|
755
|
+
return overrides
|
|
724
756
|
|
|
725
757
|
|
|
726
758
|
def _native_extents(
|
|
@@ -780,12 +812,15 @@ def _place_nodes(
|
|
|
780
812
|
for v in comp_vertices:
|
|
781
813
|
ranks[sug.grx[v].rank].append(v)
|
|
782
814
|
|
|
815
|
+
rank_of = {v.data: sug.grx[v].rank for v in comp_vertices}
|
|
816
|
+
gap_overrides = _rank_gap_overrides(edges, rank_of)
|
|
817
|
+
|
|
783
818
|
band_top_for_rank: dict[int, int] = {}
|
|
784
819
|
cursor = 0
|
|
785
820
|
for r in sorted(ranks):
|
|
786
821
|
band_top_for_rank[r] = cursor
|
|
787
822
|
max_h = max(native[v.data][1] for v in ranks[r])
|
|
788
|
-
cursor += max_h + _ROW_GAP
|
|
823
|
+
cursor += max_h + gap_overrides.get(r, _ROW_GAP)
|
|
789
824
|
|
|
790
825
|
min_x = min(v.view.xy[0] for v in comp_vertices)
|
|
791
826
|
provisional = {v: round(v.view.xy[0] - min_x) for v in comp_vertices}
|
|
@@ -1214,6 +1249,17 @@ def _label_positions(length: int, center: int, lo: int, hi: int) -> list[int]:
|
|
|
1214
1249
|
return order
|
|
1215
1250
|
|
|
1216
1251
|
|
|
1252
|
+
def _reserve_label_margin(canvas: Canvas, x: int, y: int) -> None:
|
|
1253
|
+
"""Mark one cell just past a label's edge as reserved, without
|
|
1254
|
+
altering whatever character (if any) already occupies it — a one-cell
|
|
1255
|
+
buffer so a second label placed on an adjacent/overlapping segment
|
|
1256
|
+
(e.g. two back-edge labels sharing a row) lands with visible separation
|
|
1257
|
+
instead of butting directly up against the first label's text."""
|
|
1258
|
+
if x < 0 or y < 0:
|
|
1259
|
+
return
|
|
1260
|
+
canvas.set_char(x, y, canvas.get_char(x, y), reserve=True)
|
|
1261
|
+
|
|
1262
|
+
|
|
1217
1263
|
def _draw_label_on_segment(
|
|
1218
1264
|
canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
|
|
1219
1265
|
) -> None:
|
|
@@ -1226,7 +1272,12 @@ def _draw_label_on_segment(
|
|
|
1226
1272
|
self-loop label placement. Shifts along the search axis to the nearest
|
|
1227
1273
|
span/row fully clear of a reserved (box) cell; if none exists, writes
|
|
1228
1274
|
at the ideal placement anyway (overflow tolerated) but skips individual
|
|
1229
|
-
reserved cells — a label never corrupts a box.
|
|
1275
|
+
reserved cells — a label never corrupts a box. Every written label cell
|
|
1276
|
+
is itself marked reserved (see :func:`layout_flowgraph`'s two-pass draw
|
|
1277
|
+
order): labels draw only after every edge's line+arrowheads are already
|
|
1278
|
+
on the canvas, so nothing draws over a label afterwards, and a *later*
|
|
1279
|
+
label's own clear-run search treats an earlier label's cells the same
|
|
1280
|
+
as a box's — skip, don't overwrite.
|
|
1230
1281
|
"""
|
|
1231
1282
|
length = visual_len(label)
|
|
1232
1283
|
if length <= 0:
|
|
@@ -1246,12 +1297,14 @@ def _draw_label_on_segment(
|
|
|
1246
1297
|
run = cells(start)
|
|
1247
1298
|
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
1248
1299
|
for (px, py), ch in zip(run, label):
|
|
1249
|
-
canvas.
|
|
1300
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1301
|
+
_reserve_label_margin(canvas, start - 1, row)
|
|
1302
|
+
_reserve_label_margin(canvas, start + length, row)
|
|
1250
1303
|
return
|
|
1251
1304
|
start = max(lo, min(center - length // 2, hi - length + 1))
|
|
1252
1305
|
for (px, py), ch in zip(cells(start), label):
|
|
1253
1306
|
if not canvas.is_reserved(px, py):
|
|
1254
|
-
canvas.
|
|
1307
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1255
1308
|
return
|
|
1256
1309
|
|
|
1257
1310
|
# Vertical segment: the label still reads horizontally, centered on
|
|
@@ -1272,11 +1325,13 @@ def _draw_label_on_segment(
|
|
|
1272
1325
|
run = row_cells(row)
|
|
1273
1326
|
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
1274
1327
|
for (px, py), ch in zip(run, label):
|
|
1275
|
-
canvas.
|
|
1328
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1329
|
+
_reserve_label_margin(canvas, start_x - 1, row)
|
|
1330
|
+
_reserve_label_margin(canvas, start_x + length, row)
|
|
1276
1331
|
return
|
|
1277
1332
|
for (px, py), ch in zip(row_cells(center_row), label):
|
|
1278
1333
|
if not canvas.is_reserved(px, py):
|
|
1279
|
-
canvas.
|
|
1334
|
+
canvas.set_char(px, py, ch, reserve=True)
|
|
1280
1335
|
|
|
1281
1336
|
|
|
1282
1337
|
def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
|
|
@@ -1322,67 +1377,61 @@ def _self_loop_points(
|
|
|
1322
1377
|
return [(ax, lo), (far, lo), (far, hi), (ax, hi)]
|
|
1323
1378
|
|
|
1324
1379
|
|
|
1325
|
-
def
|
|
1326
|
-
canvas: Canvas,
|
|
1380
|
+
def _route_edge_path(
|
|
1327
1381
|
rects: dict[str, BoxRect],
|
|
1328
1382
|
direction: Direction,
|
|
1329
1383
|
edge: FlowEdge,
|
|
1330
1384
|
lane_counter: list[int],
|
|
1331
1385
|
self_loop_counter: dict[str, int],
|
|
1332
|
-
) -> None:
|
|
1333
|
-
"""
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1386
|
+
) -> list[tuple[int, int]] | None:
|
|
1387
|
+
"""Compute one edge's polyline points: anchors chosen by relative rank
|
|
1388
|
+
position, an L/Z/C path shape. Pure path computation only — drawing is
|
|
1389
|
+
the caller's job, split into two passes across *all* edges (see
|
|
1390
|
+
:func:`layout_flowgraph`) so that no edge's line ever overwrites
|
|
1391
|
+
another edge's already-placed label. Dangling node-id references
|
|
1392
|
+
return ``None`` (defensive no-op — the parser guarantees valid
|
|
1393
|
+
endpoints; this module never crashes on one).
|
|
1337
1394
|
"""
|
|
1338
1395
|
src_rect = rects.get(edge.src)
|
|
1339
1396
|
dst_rect = rects.get(edge.dst)
|
|
1340
1397
|
if src_rect is None or dst_rect is None:
|
|
1341
|
-
return
|
|
1398
|
+
return None
|
|
1342
1399
|
|
|
1343
1400
|
if edge.src == edge.dst:
|
|
1344
1401
|
lane = self_loop_counter.get(edge.src, 0)
|
|
1345
1402
|
self_loop_counter[edge.src] = lane + 1
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1403
|
+
return _self_loop_points(direction, src_rect, lane)
|
|
1404
|
+
|
|
1405
|
+
src_extent = _rank_extent(direction, src_rect)
|
|
1406
|
+
dst_extent = _rank_extent(direction, dst_rect)
|
|
1407
|
+
same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
|
|
1408
|
+
|
|
1409
|
+
if same_rank:
|
|
1410
|
+
return [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
|
|
1411
|
+
|
|
1412
|
+
src_center = (src_extent[0] + src_extent[1]) / 2
|
|
1413
|
+
dst_center = (dst_extent[0] + dst_extent[1]) / 2
|
|
1414
|
+
forward = (dst_center > src_center) == _forward_increasing(direction)
|
|
1415
|
+
if forward:
|
|
1416
|
+
return _z_path(
|
|
1417
|
+
direction,
|
|
1418
|
+
_forward_exit(direction, src_rect),
|
|
1419
|
+
_forward_entry(direction, dst_rect),
|
|
1420
|
+
)
|
|
1351
1421
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
lane = lane_counter[0]
|
|
1366
|
-
lane_counter[0] += 1
|
|
1367
|
-
lane_secondary = (
|
|
1368
|
-
_lane_secondary_base(direction, src_rect, dst_rect)
|
|
1369
|
-
+ _LANE_MARGIN
|
|
1370
|
-
+ lane * _LANE_GAP
|
|
1371
|
-
)
|
|
1372
|
-
points = _lane_path(
|
|
1373
|
-
direction,
|
|
1374
|
-
_lane_anchor(direction, src_rect),
|
|
1375
|
-
_lane_anchor(direction, dst_rect),
|
|
1376
|
-
lane_secondary,
|
|
1377
|
-
)
|
|
1378
|
-
|
|
1379
|
-
_draw_polyline(canvas, points, edge.style)
|
|
1380
|
-
_draw_arrowheads(canvas, points, edge)
|
|
1381
|
-
|
|
1382
|
-
if edge.label:
|
|
1383
|
-
seg = _longest_segment(points)
|
|
1384
|
-
if seg is not None:
|
|
1385
|
-
_draw_label_on_segment(canvas, *seg, edge.label)
|
|
1422
|
+
lane = lane_counter[0]
|
|
1423
|
+
lane_counter[0] += 1
|
|
1424
|
+
lane_secondary = (
|
|
1425
|
+
_lane_secondary_base(direction, src_rect, dst_rect)
|
|
1426
|
+
+ _LANE_MARGIN
|
|
1427
|
+
+ lane * _LANE_GAP
|
|
1428
|
+
)
|
|
1429
|
+
return _lane_path(
|
|
1430
|
+
direction,
|
|
1431
|
+
_lane_anchor(direction, src_rect),
|
|
1432
|
+
_lane_anchor(direction, dst_rect),
|
|
1433
|
+
lane_secondary,
|
|
1434
|
+
)
|
|
1386
1435
|
|
|
1387
1436
|
|
|
1388
1437
|
# --------------------------------------------------------------------------
|
|
@@ -1451,10 +1500,27 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
|
|
|
1451
1500
|
rect = rects.get(n.id)
|
|
1452
1501
|
if rect is not None:
|
|
1453
1502
|
canvas.draw_box(rect, n.shape, n.label)
|
|
1503
|
+
# Two passes across *all* edges, not one pass per edge: every
|
|
1504
|
+
# edge's line + arrowheads draw first, then every edge's label
|
|
1505
|
+
# draws last (see design doc). Interleaving line-then-label per
|
|
1506
|
+
# edge (the old order) let a later edge's line silently erase an
|
|
1507
|
+
# earlier edge's already-placed label whenever their paths shared
|
|
1508
|
+
# a cell — labels are only ever safe once no more lines are coming.
|
|
1454
1509
|
lane_counter = [0]
|
|
1455
1510
|
self_loop_counter: dict[str, int] = {}
|
|
1511
|
+
edge_paths: list[tuple[FlowEdge, list[tuple[int, int]]]] = []
|
|
1456
1512
|
for e in g.edges:
|
|
1457
|
-
|
|
1513
|
+
points = _route_edge_path(rects, g.direction, e, lane_counter, self_loop_counter)
|
|
1514
|
+
if points is not None:
|
|
1515
|
+
edge_paths.append((e, points))
|
|
1516
|
+
for e, points in edge_paths:
|
|
1517
|
+
_draw_polyline(canvas, points, e.style)
|
|
1518
|
+
_draw_arrowheads(canvas, points, e)
|
|
1519
|
+
for e, points in edge_paths:
|
|
1520
|
+
if e.label:
|
|
1521
|
+
seg = _longest_segment(points)
|
|
1522
|
+
if seg is not None:
|
|
1523
|
+
_draw_label_on_segment(canvas, *seg, e.label)
|
|
1458
1524
|
return canvas.to_lines()
|
|
1459
1525
|
except Exception:
|
|
1460
1526
|
return []
|