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.
Files changed (77) hide show
  1. {termrender-4.6.0 → termrender-4.6.2}/CHANGELOG.md +64 -0
  2. {termrender-4.6.0 → termrender-4.6.2}/PKG-INFO +1 -1
  3. termrender-4.6.2/scripts/mermaid_flow_parity.py +168 -0
  4. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow.py +35 -11
  5. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_layout.py +120 -54
  6. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_parser.py +128 -59
  7. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow.py +87 -2
  8. termrender-4.6.2/tests/test_mermaid_flow_corpus.py +448 -0
  9. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_layout.py +108 -18
  10. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_parser.py +91 -0
  11. {termrender-4.6.0 → termrender-4.6.2}/.github/workflows/publish.yml +0 -0
  12. {termrender-4.6.0 → termrender-4.6.2}/.gitignore +0 -0
  13. {termrender-4.6.0 → termrender-4.6.2}/CLAUDE.md +0 -0
  14. {termrender-4.6.0 → termrender-4.6.2}/LICENSE +0 -0
  15. {termrender-4.6.0 → termrender-4.6.2}/README.md +0 -0
  16. {termrender-4.6.0 → termrender-4.6.2}/design.json +0 -0
  17. {termrender-4.6.0 → termrender-4.6.2}/pyproject.toml +0 -0
  18. {termrender-4.6.0 → termrender-4.6.2}/requirements.json +0 -0
  19. {termrender-4.6.0 → termrender-4.6.2}/scripts/build-mermaid-ascii.sh +0 -0
  20. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/CLAUDE.md +0 -0
  21. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/__init__.py +0 -0
  22. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/__main__.py +0 -0
  23. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  24. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/_mermaid_bin.py +0 -0
  25. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/blocks.py +0 -0
  26. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/emit.py +0 -0
  27. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/layout.py +0 -0
  28. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/parser.py +0 -0
  29. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/py.typed +0 -0
  30. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/CLAUDE.md +0 -0
  31. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/__init__.py +0 -0
  32. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/borders.py +0 -0
  33. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/charts.py +0 -0
  34. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/code.py +0 -0
  35. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/columns.py +0 -0
  36. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/diff.py +0 -0
  37. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/divider.py +0 -0
  38. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid.py +0 -0
  39. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  40. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
  41. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_journey.py +0 -0
  42. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  43. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_pie.py +0 -0
  44. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
  45. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
  46. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/mermaid_timeline.py +0 -0
  47. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/panel.py +0 -0
  48. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/quote.py +0 -0
  49. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/stat.py +0 -0
  50. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/table.py +0 -0
  51. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/text.py +0 -0
  52. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/timeline.py +0 -0
  53. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/renderers/tree.py +0 -0
  54. {termrender-4.6.0 → termrender-4.6.2}/src/termrender/style.py +0 -0
  55. {termrender-4.6.0 → termrender-4.6.2}/tests/__init__.py +0 -0
  56. {termrender-4.6.0 → termrender-4.6.2}/tests/test_charts.py +0 -0
  57. {termrender-4.6.0 → termrender-4.6.2}/tests/test_cli_contract.py +0 -0
  58. {termrender-4.6.0 → termrender-4.6.2}/tests/test_column_alignment.py +0 -0
  59. {termrender-4.6.0 → termrender-4.6.2}/tests/test_diff.py +0 -0
  60. {termrender-4.6.0 → termrender-4.6.2}/tests/test_inline_badge.py +0 -0
  61. {termrender-4.6.0 → termrender-4.6.2}/tests/test_linebreak.py +0 -0
  62. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_compat.py +0 -0
  63. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_dispatch.py +0 -0
  64. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_model.py +0 -0
  65. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_flow_shapes.py +0 -0
  66. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_gantt.py +0 -0
  67. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_journey.py +0 -0
  68. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_mindmap.py +0 -0
  69. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_pie.py +0 -0
  70. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_sequence.py +0 -0
  71. {termrender-4.6.0 → termrender-4.6.2}/tests/test_mermaid_timeline.py +0 -0
  72. {termrender-4.6.0 → termrender-4.6.2}/tests/test_myst_gaps.py +0 -0
  73. {termrender-4.6.0 → termrender-4.6.2}/tests/test_stat.py +0 -0
  74. {termrender-4.6.0 → termrender-4.6.2}/tests/test_tasklist.py +0 -0
  75. {termrender-4.6.0 → termrender-4.6.2}/tests/test_timeline.py +0 -0
  76. {termrender-4.6.0 → termrender-4.6.2}/tests/test_variable_colons.py +0 -0
  77. {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.0
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/]`` — this phase renders every shape
29
- as a plain rectangle (distinct shape borders are a later phase; only the
30
- label text is load-bearing here). Edges: ``-->``, ``---``, ``-.->``, ``==>``
31
- (and bidirectional forms), with ``|label|`` and inline ``A -- text --> B``
32
- labels, and ``&`` fan-out. ``subgraph ... end`` blocks parse but are not yet
33
- rendered as frames (nodes inside them still place and render normally).
34
- ``class``/``classDef``/``style``/``click``/``linkStyle`` lines are ignored.
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
- - Node shapes other than a plain rectangle border are not yet visually
39
- distinct (Phase 4 of this renderer's build).
40
- - Subgraph blocks parse but are not yet drawn as enclosing frames (Phase 4).
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
- return [line.rstrip() for line in source.splitlines()]
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.draw_glyph(px, py, ch)
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.draw_glyph(px, py, ch)
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.draw_glyph(px, py, ch)
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.draw_glyph(px, py, ch)
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 _route_edge(
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
- """Draw one edge's full orthogonal path: anchors chosen by relative
1334
- rank position, an L/Z/C path shape, arrowheads, and an edge label.
1335
- Dangling node-id references are a defensive no-op (the parser
1336
- guarantees valid endpoints; this module never crashes on one).
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
- points = _self_loop_points(direction, src_rect, lane)
1347
- else:
1348
- src_extent = _rank_extent(direction, src_rect)
1349
- dst_extent = _rank_extent(direction, dst_rect)
1350
- same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
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
- if same_rank:
1353
- points = [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
1354
- else:
1355
- src_center = (src_extent[0] + src_extent[1]) / 2
1356
- dst_center = (dst_extent[0] + dst_extent[1]) / 2
1357
- forward = (dst_center > src_center) == _forward_increasing(direction)
1358
- if forward:
1359
- points = _z_path(
1360
- direction,
1361
- _forward_exit(direction, src_rect),
1362
- _forward_entry(direction, dst_rect),
1363
- )
1364
- else:
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
- _route_edge(canvas, rects, g.direction, e, lane_counter, self_loop_counter)
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 []