termrender 4.9.1__tar.gz → 4.10.1__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 (85) hide show
  1. {termrender-4.9.1 → termrender-4.10.1}/CHANGELOG.md +56 -0
  2. {termrender-4.9.1 → termrender-4.10.1}/PKG-INFO +4 -5
  3. {termrender-4.9.1 → termrender-4.10.1}/README.md +3 -3
  4. {termrender-4.9.1 → termrender-4.10.1}/pyproject.toml +0 -6
  5. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/CLAUDE.md +3 -9
  6. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/borders.py +4 -3
  7. termrender-4.10.1/src/termrender/renderers/mermaid.py +117 -0
  8. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_flow_layout.py +31 -7
  9. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_dispatch.py +116 -58
  10. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_er.py +60 -0
  11. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_flow_corpus.py +7 -10
  12. termrender-4.10.1/uv.lock +70 -0
  13. termrender-4.9.1/scripts/build-mermaid-ascii.sh +0 -36
  14. termrender-4.9.1/scripts/mermaid_flow_parity.py +0 -168
  15. termrender-4.9.1/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  16. termrender-4.9.1/src/termrender/_mermaid_bin.py +0 -41
  17. termrender-4.9.1/src/termrender/renderers/mermaid.py +0 -214
  18. termrender-4.9.1/tests/test_mermaid_compat.py +0 -151
  19. termrender-4.9.1/uv.lock +0 -94
  20. {termrender-4.9.1 → termrender-4.10.1}/.github/workflows/publish.yml +0 -0
  21. {termrender-4.9.1 → termrender-4.10.1}/.gitignore +0 -0
  22. {termrender-4.9.1 → termrender-4.10.1}/CLAUDE.md +0 -0
  23. {termrender-4.9.1 → termrender-4.10.1}/LICENSE +0 -0
  24. {termrender-4.9.1 → termrender-4.10.1}/design.json +0 -0
  25. {termrender-4.9.1 → termrender-4.10.1}/requirements.json +0 -0
  26. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/CLAUDE.md +0 -0
  27. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/__init__.py +0 -0
  28. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/__main__.py +0 -0
  29. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/blocks.py +0 -0
  30. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/emit.py +0 -0
  31. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/layout.py +0 -0
  32. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/parser.py +0 -0
  33. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/py.typed +0 -0
  34. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/__init__.py +0 -0
  35. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/charts.py +0 -0
  36. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/code.py +0 -0
  37. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/columns.py +0 -0
  38. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/diff.py +0 -0
  39. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/divider.py +0 -0
  40. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_class.py +0 -0
  41. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_er.py +0 -0
  42. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_flow.py +0 -0
  43. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  44. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  45. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_gantt.py +0 -0
  46. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_journey.py +0 -0
  47. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  48. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_pie.py +0 -0
  49. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
  50. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
  51. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_state.py +0 -0
  52. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
  53. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/panel.py +0 -0
  54. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/quote.py +0 -0
  55. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/stat.py +0 -0
  56. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/table.py +0 -0
  57. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/text.py +0 -0
  58. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/timeline.py +0 -0
  59. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/renderers/tree.py +0 -0
  60. {termrender-4.9.1 → termrender-4.10.1}/src/termrender/style.py +0 -0
  61. {termrender-4.9.1 → termrender-4.10.1}/tests/__init__.py +0 -0
  62. {termrender-4.9.1 → termrender-4.10.1}/tests/test_charts.py +0 -0
  63. {termrender-4.9.1 → termrender-4.10.1}/tests/test_cli_contract.py +0 -0
  64. {termrender-4.9.1 → termrender-4.10.1}/tests/test_column_alignment.py +0 -0
  65. {termrender-4.9.1 → termrender-4.10.1}/tests/test_diff.py +0 -0
  66. {termrender-4.9.1 → termrender-4.10.1}/tests/test_inline_badge.py +0 -0
  67. {termrender-4.9.1 → termrender-4.10.1}/tests/test_linebreak.py +0 -0
  68. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_class.py +0 -0
  69. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_flow.py +0 -0
  70. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_flow_layout.py +0 -0
  71. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_flow_model.py +0 -0
  72. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_flow_parser.py +0 -0
  73. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_flow_shapes.py +0 -0
  74. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_gantt.py +0 -0
  75. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_journey.py +0 -0
  76. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_mindmap.py +0 -0
  77. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_pie.py +0 -0
  78. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_sequence.py +0 -0
  79. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_state.py +0 -0
  80. {termrender-4.9.1 → termrender-4.10.1}/tests/test_mermaid_timeline.py +0 -0
  81. {termrender-4.9.1 → termrender-4.10.1}/tests/test_myst_gaps.py +0 -0
  82. {termrender-4.9.1 → termrender-4.10.1}/tests/test_stat.py +0 -0
  83. {termrender-4.9.1 → termrender-4.10.1}/tests/test_tasklist.py +0 -0
  84. {termrender-4.9.1 → termrender-4.10.1}/tests/test_timeline.py +0 -0
  85. {termrender-4.9.1 → termrender-4.10.1}/tests/test_variable_colons.py +0 -0
@@ -1,6 +1,62 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.10.1 (2026-07-07)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid-flow**: Constant inter-layer gap regardless of node height
9
+ ([`eb1726f`](https://github.com/crouton-labs/termrender/commit/eb1726fa4bd05a57d29026b10715edc067ac4113))
10
+
11
+ The rank-band gap override that widens spacing to fit a labeled edge scaled with visual_len(label)
12
+ (the label's *text width*) even on TB/BT diagrams, where the label is drawn horizontally across a
13
+ vertical connector and only ever needs one clear row, not one row per character. Since ER/class
14
+ diagrams route almost every edge through a label (relationship text, cardinality), this made
15
+ compartmented-node TB layouts burn ~15-17 blank connector rows per edge hop while 1-line-node
16
+ flowcharts stayed compact — node height was a red herring; the real driver was label width feeding
17
+ a row count.
18
+
19
+ _rank_gap_overrides is now direction-aware: LR/RL keeps the width-scaled gap (the transpose turns
20
+ that native row-band into the final horizontal run the label reads along, so it genuinely needs
21
+ the width). TB/BT gets a small constant, _LABELED_ROW_GAP=3, regardless of label length.
22
+
23
+ Updates the two TB labeled-edge goldens in test_mermaid_flow_corpus.py to the new compact spacing,
24
+ and adds a regression test in test_mermaid_er.py pinning a row budget for compartmented entities
25
+ so this doesn't regress.
26
+
27
+
28
+ ## v4.10.0 (2026-07-07)
29
+
30
+ ### Features
31
+
32
+ - **mermaid**: Native dispatch for flowchart/class/state/er, drop vendored Go binary
33
+ ([`6935f30`](https://github.com/crouton-labs/termrender/commit/6935f30a1dccfefcc34344de02191f8481d53668))
34
+
35
+ Wire the four previously-unwired native renderers into mermaid.py's first-line dispatcher:
36
+ flowchart/graph -> mermaid_flow.render_flowchart, classDiagram -> mermaid_class.render_class,
37
+ stateDiagram/-v2 -> mermaid_state.render_state, erDiagram -> mermaid_er.render_er. Every mermaid
38
+ type termrender renders is now native Python; no diagram type shells out anymore.
39
+
40
+ Unrecognized/exotic types (sankey, C4, gitgraph, block, packet, kanban, quadrantChart, ...) degrade
41
+ in the dispatcher itself to a raw echo of the source with box-drawing/geometric glyphs stripped,
42
+ preserving the contract the crouter attach viewer relies on to detect render failure by glyph
43
+ absence.
44
+
45
+ Delete the vendored Go path entirely: src/termrender/_bin/ (binary), src/termrender/_mermaid_bin.py,
46
+ scripts/build-mermaid-ascii.sh, the mermaid-ascii runtime dependency and hatch artifacts stanza in
47
+ pyproject.toml (relocked via uv.lock), and scripts/mermaid_flow_parity.py (a standalone dev-only
48
+ harness comparing native output against the now-deleted binary).
49
+
50
+ tests/test_mermaid_compat.py pinned Go-era preprocessing (preprocess_mermaid_for_ascii) that no
51
+ longer exists; deleted wholesale since dispatch/prelude contracts it also touched already live in
52
+ test_mermaid_dispatch.py. That file's routing tests are updated for the four newly-wired types and
53
+ gain dispatch tests for the exotic raw-echo path (including a no-native-renderer-called assertion
54
+ and a glyph-range sweep pinning the no-box-glyph contract).
55
+
56
+ Swept src/termrender/renderers/CLAUDE.md and README.md to current-state only; no remaining
57
+ references to mermaid-ascii/_mermaid_bin/the Go binary anywhere in the tree.
58
+
59
+
4
60
  ## v4.9.1 (2026-07-07)
5
61
 
6
62
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.9.1
3
+ Version: 4.10.1
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,6 @@ Classifier: Topic :: Terminals
22
22
  Classifier: Topic :: Text Processing :: Markup :: Markdown
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: grandalf>=0.8
25
- Requires-Dist: mermaid-ascii>=1.2
26
25
  Requires-Dist: mistune>=3.0
27
26
  Requires-Dist: pygments>=2.0
28
27
  Description-Content-Type: text/markdown
@@ -163,7 +162,7 @@ That entire output — the nested borders, the tree guide lines, the side-by-sid
163
162
  pip install termrender
164
163
  ```
165
164
 
166
- Python 3.10+. Three dependencies: [mistune](https://github.com/lepture/mistune) (markdown parsing), [pygments](https://pygments.org/) (syntax highlighting), [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii) (diagram rendering).
165
+ Python 3.10+. Three dependencies: [mistune](https://github.com/lepture/mistune) (markdown parsing), [pygments](https://pygments.org/) (syntax highlighting), [grandalf](https://github.com/bdcht/grandalf) (mermaid diagram layout).
167
166
 
168
167
 
169
168
  ## Usage
@@ -409,7 +408,7 @@ Horizontal rules with optional centered labels.
409
408
 
410
409
  ### Mermaid diagrams
411
410
 
412
- Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Use the `:::mermaid` directive, or a standard GFM ` ```mermaid ` fenced code block — both render the same diagram.
411
+ Renders mermaid diagrams (flowcharts, class/state/ER diagrams, pie/gantt/sequence/mindmap/journey/timeline charts) natively as ASCII/unicode art. Use the `:::mermaid` directive, or a standard GFM ` ```mermaid ` fenced code block — both render the same diagram.
413
412
 
414
413
  **Input:**
415
414
  ```markdown
@@ -421,7 +420,7 @@ graph LR
421
420
  :::
422
421
  ```
423
422
 
424
- The diagram gets rendered as ASCII art inline in the terminal output. Requires `mermaid-ascii` to be installed (it's a dependency, so it should be).
423
+ The diagram gets rendered as ASCII art inline in the terminal output, no external binary required.
425
424
 
426
425
 
427
426
  ### Nesting
@@ -134,7 +134,7 @@ That entire output — the nested borders, the tree guide lines, the side-by-sid
134
134
  pip install termrender
135
135
  ```
136
136
 
137
- Python 3.10+. Three dependencies: [mistune](https://github.com/lepture/mistune) (markdown parsing), [pygments](https://pygments.org/) (syntax highlighting), [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii) (diagram rendering).
137
+ Python 3.10+. Three dependencies: [mistune](https://github.com/lepture/mistune) (markdown parsing), [pygments](https://pygments.org/) (syntax highlighting), [grandalf](https://github.com/bdcht/grandalf) (mermaid diagram layout).
138
138
 
139
139
 
140
140
  ## Usage
@@ -380,7 +380,7 @@ Horizontal rules with optional centered labels.
380
380
 
381
381
  ### Mermaid diagrams
382
382
 
383
- Renders mermaid flowcharts as ASCII art via [mermaid-ascii](https://github.com/mermaid-js/mermaid-ascii). Use the `:::mermaid` directive, or a standard GFM ` ```mermaid ` fenced code block — both render the same diagram.
383
+ Renders mermaid diagrams (flowcharts, class/state/ER diagrams, pie/gantt/sequence/mindmap/journey/timeline charts) natively as ASCII/unicode art. Use the `:::mermaid` directive, or a standard GFM ` ```mermaid ` fenced code block — both render the same diagram.
384
384
 
385
385
  **Input:**
386
386
  ```markdown
@@ -392,7 +392,7 @@ graph LR
392
392
  :::
393
393
  ```
394
394
 
395
- The diagram gets rendered as ASCII art inline in the terminal output. Requires `mermaid-ascii` to be installed (it's a dependency, so it should be).
395
+ The diagram gets rendered as ASCII art inline in the terminal output, no external binary required.
396
396
 
397
397
 
398
398
  ### Nesting
@@ -28,7 +28,6 @@ classifiers = [
28
28
  ]
29
29
  dependencies = [
30
30
  "mistune>=3.0",
31
- "mermaid-ascii>=1.2",
32
31
  "pygments>=2.0",
33
32
  "grandalf>=0.8",
34
33
  ]
@@ -46,11 +45,6 @@ source = "vcs"
46
45
 
47
46
  [tool.hatch.build.targets.wheel]
48
47
  packages = ["src/termrender"]
49
- # Ship the vendored mermaid-ascii binary (built from pinned upstream master;
50
- # see scripts/build-mermaid-ascii.sh). It lives inside the package dir so the
51
- # default file selection includes it; artifacts ensures it survives even if
52
- # VCS-ignored, and hatchling preserves its 0o755 exec bit.
53
- artifacts = ["src/termrender/_bin/*"]
54
48
 
55
49
  [tool.semantic_release]
56
50
  version_toml = []
@@ -43,20 +43,14 @@ Attribution line is rendered for `block.attrs["author"]` **or** `block.attrs["by
43
43
 
44
44
  `mermaid.py`'s `render(block, color)` (leaf signature, no `render_child` — the parent `CLAUDE.md` incorrectly lists mermaid as a Container renderer) is a thin wrapper: it reads `block.attrs["_rendered"]` if layout already precomputed it, else calls `render_mermaid_lines(source, width)`, then pads every line to `block.width` with `visual_ljust`. All the type-routing logic lives in `render_mermaid_lines`, shared with `layout.py`'s height pass (see below) so there is exactly one dispatch path, not two.
45
45
 
46
- **Dispatch is by first real line, case-insensitive** (`_first_line_type`), after skipping any prelude via `mermaid_prelude.strip_prelude_lines` (see below): `pie`, `gantt`, `sequenceDiagram`, `mindmap`, `journey`, and `timeline` route to native Python renderers (`mermaid_pie.py`, `mermaid_gantt.py`, `mermaid_sequence.py`, `mermaid_mindmap.py`, `mermaid_journey.py`, `mermaid_timeline.py`). Every other type `graph`/`flowchart`, `classDiagram`, `stateDiagram`, `erDiagram`, routes to `_render_via_binary`, the pre-existing single path through the vendored Go `mermaid-ascii` binary. **Only the six listed types have native renderers; every other type's degradation is byte-for-byte what it was before this dispatcher existed** — do not add a bordered fallback panel for them, since crouter's attach viewer detects render failure by the *absence* of box-drawing glyphs in the output, and a decorative border would defeat that.
46
+ **Dispatch is by first real line, case-insensitive** (`_first_line_type`), after skipping any prelude via `mermaid_prelude.strip_prelude_lines` (see below): `pie`, `gantt`, `sequenceDiagram`, `mindmap`, `journey`, `timeline`, `graph`/`flowchart`, `classDiagram`, `stateDiagram`/`stateDiagram-v2`, and `erDiagram` all route to dedicated native Python renderers (`mermaid_pie.py`, `mermaid_gantt.py`, `mermaid_sequence.py`, `mermaid_mindmap.py`, `mermaid_journey.py`, `mermaid_timeline.py`, `mermaid_flow.py::render_flowchart`, `mermaid_class.py::render_class`, `mermaid_state.py::render_state`, `mermaid_er.py::render_er`). Every other type (sankey, C4, gitgraph, block, packet, kanban, quadrantChart, …, and anything unrecognized) has no renderer at all and degrades in the dispatcher itself, via `_raw_echo`: the source lines verbatim, `.rstrip()`-ed, with any box-drawing/geometric glyph (`\u2500-\u259f`/`\u25a0-\u25ff`) replaced by `?`. **Do not add a bordered fallback panel for the exotic path** — crouter's attach viewer detects render failure by the *absence* of box-drawing glyphs in the output, and a decorative border would defeat that; the four flowchart-model-backed native renderers (flow/class/state/ER) uphold the identical no-glyph guarantee on their own internal degradation paths (malformed input, wrong header, zero nodes/classes/states/entities parsed, or an unexpected exception escaping layout) — see each module's own docstring.
47
47
 
48
- **`mermaid_prelude.py`** — a tiny shared module (no dependency on `Block` or the rest of the pipeline) implementing `strip_prelude_lines(lines)`, used by both `mermaid.py`'s dispatch sniff and `mermaid_sequence.py`'s own header check. It skips leading blank lines, `%%` line comments, `%%{init: ...}%%` directives, and `---`-delimited YAML frontmatter, permissively: an unterminated frontmatter block or directive is left alone rather than eating real content. Prelude stripping is for *sniffing/validating the type line only* — the per-type body parsers still receive the full untouched source; `mermaid_sequence.py`'s `_parse` already tolerates arbitrary prelude lines before its header (they fail every line pattern and are skipped via its `seen_header` gate), and `mermaid_pie.py`/`mermaid_gantt.py`/the mindmap/journey/timeline parsers likewise just skip lines that don't match their grammar — a frontmatter `title: X` line could in principle collide with a `title` grammar rule, but this is accepted as the same class of pragmatic-parsing tradeoff as any other unrecognized line, not something the prelude skip needs to prevent.
48
+ **`mermaid_prelude.py`** — a tiny shared module (no dependency on `Block` or the rest of the pipeline) implementing `strip_prelude_lines(lines)`, used by `mermaid.py`'s dispatch sniff and by every native renderer's own header check (`mermaid_sequence.py`, `mermaid_flow_parser.py`, `mermaid_class.py`, `mermaid_state.py`, `mermaid_er.py`). It skips leading blank lines, `%%` line comments, `%%{init: ...}%%` directives, and `---`-delimited YAML frontmatter, permissively: an unterminated frontmatter block or directive is left alone rather than eating real content. Prelude stripping is for *sniffing/validating the type line only* — most per-type body parsers receive the full untouched source and do their own prelude-aware header check internally (flow/class/state/ER/sequence); `mindmap`/`journey`/`timeline` are the exception, called with `mermaid.py::_stripped(source)` since those parsers don't skip prelude themselves. `mermaid_pie.py`/`mermaid_gantt.py` just skip lines that don't match their grammar — a frontmatter `title: X` line could in principle collide with a `title` grammar rule, but this is accepted as the same class of pragmatic-parsing tradeoff as any other unrecognized line.
49
49
 
50
- **`_render_via_binary`** (used for flowchart/graph/class/state/ER/everything-without-a-native-renderer): normalizes via `preprocess_mermaid_for_ascii`, shells out with `check=True`, and undoes the Latin-1 double-encoding via `fix_mermaid_encoding` (`mermaid-ascii` misreads UTF-8 input as Latin-1 and re-encodes, corrupting multi-byte characters — e.g. `→` becomes `â\x86\x92`; `text.encode("latin-1").decode("utf-8")` reverses it, and on failure silently returns the corrupted string). Any failure — `CalledProcessError` (including a non-zero exit, now actually checked via `check=True`), missing binary, or timeout degrades to the raw source text. A labeled back-edge (`X -->|lbl| Y` in `graph LR`) panics the binary on both the PyPI 1.2.0 wheel and vendored master; the resulting non-zero exit degrades the same way. Note `preprocess_mermaid_for_ascii` has its own independent first-line sniff (not `mermaid_prelude`-aware) to decide sequence-vs-flowchart-vs-other normalization a prelude ahead of `graph`/`flowchart`/`sequenceDiagram` here means no shape/arrow normalization runs, an existing (unfixed) limitation of the binary path, orthogonal to dispatch routing.
51
-
52
- **`visual_ljust` pads but never truncates**: every output line (from any type — binary or native) is padded to `block.width` in the outer `render()`. Lines wider than `block.width` overflow without clipping. `mermaid-ascii` has no width-control flag (`--maxWidth`/`-w` do not exist — the call passes only `-f - -y 1`), so wide flowchart output silently exceeds the layout boundary; the native renderers respect `width` by construction (pie/gantt/timeline size their own bars to it; sequence/mindmap/journey have intrinsic width and may overflow, matching the binary path's contract) — see each renderer's own note below.
50
+ **`visual_ljust` pads but never truncates**: every output line (from any type) is padded to `block.width` in the outer `render()`. Lines wider than `block.width` overflow without clippingpie/gantt/timeline size their own bars to `width`; sequence/mindmap/journey and the four flowchart-model-backed renderers (flow/class/state/ER) have intrinsic content-driven width and may overflow, `width` there is advisory only.
53
51
 
54
52
  Layout (`layout.py::resolve_height`, `BlockType.MERMAID` branch) precomputes `block.attrs["_rendered"]` by calling the same `render_mermaid_lines`, so `mermaid.py`'s `render()` only recomputes when that key is absent (e.g. calling `render()` on a block that skipped the layout pass).
55
53
 
56
- **Flowchart node shapes are normalized to rectangles**: `preprocess_mermaid_for_ascii` rewrites every non-rectangle flowchart node shape (`{rhombus}`, `[(cylinder)]`, `((circle))`, `([stadium])`, `{{hexagon}}`, `[[subroutine]]`, `[/parallelogram/]`) to the `id[label]` form via `_FLOWCHART_SHAPE_SUBS`, because mermaid-ascii's `parseNode` only recognizes `[text]` rectangles — other shapes otherwise leak raw delimiters or the bare node id into the box. This is a permanent workaround: node shapes are unsupported on current upstream master (pin `6fffb8e`) and the 6 newer master commits are arrow-parsing only, so don't expect a pin bump to remove the need. The backend draws a rectangle regardless, so only the label text matters.
57
-
58
- **Binary resolution**: the executable comes from `_mermaid_bin.mermaid_ascii_bin()` — the vendored `_bin/mermaid-ascii-<os>-<arch>` (pinned upstream master `6fffb8e`, built via `scripts/build-mermaid-ascii.sh`) if present for the platform, else `mermaid-ascii` on PATH (the PyPI wheel, capped at 1.2.0).
59
-
60
54
  ## `mermaid_pie.py` — re-expressed as a bar chart, not a text circle
61
55
 
62
56
  A text pie chart is worse than a labeled bar chart at the same information density (per the ratified plan), so `parse_pie` extracts `(title, items)` from the `pie [showData]` / `title …` / `"label" : value` grammar and `render` hands the items to `charts.py::render_bar` via a synthetic `Block(type=BlockType.BAR, ...)` — no duplicated bar-drawing logic. Each item's `unit` attr is set to the pre-formatted percentage string (e.g. `" (40.0%)"`) so `render_bar`'s existing value-formatting appends it for free. Called with `color=False` always — mermaid output is monochrome by design (the attach viewer relies on `--color on == --color off`). Unparseable/empty input degrades to the raw source lines, same contract as the binary path.
@@ -31,9 +31,10 @@ def render_box(
31
31
  corner_v = visual_len("┌") # same as ┐, └, ┘
32
32
 
33
33
  # Grow the box if any content line (or the title) won't fit at the
34
- # requested width. mermaid-ascii has no width-control flag, so a child
35
- # mermaid block can return lines wider than its allocated content area.
36
- # Truncating would corrupt the diagram; growing keeps the box's top,
34
+ # requested width. Several mermaid renderers size to content rather than
35
+ # to width (flowchart/class/state/ER/sequence/mindmap/journey), so a
36
+ # child mermaid block can return lines wider than its allocated content
37
+ # area. Truncating would corrupt the diagram; growing keeps the box's top,
37
38
  # bottom, and side borders aligned at the same column even if it
38
39
  # overflows the parent's allocation.
39
40
  content_max = visual_len("")
@@ -0,0 +1,117 @@
1
+ """Mermaid diagram renderer for termrender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from termrender.blocks import Block
8
+ from termrender.renderers import (
9
+ mermaid_class,
10
+ mermaid_er,
11
+ mermaid_flow,
12
+ mermaid_gantt,
13
+ mermaid_journey,
14
+ mermaid_mindmap,
15
+ mermaid_pie,
16
+ mermaid_sequence,
17
+ mermaid_state,
18
+ mermaid_timeline,
19
+ )
20
+ from termrender.renderers.mermaid_prelude import strip_prelude_lines
21
+ from termrender.style import visual_ljust
22
+
23
+ # Same box-drawing/geometric glyph ranges the native renderers (mermaid_flow,
24
+ # mermaid_class, mermaid_state, mermaid_er) guard their own raw-echo paths
25
+ # with, and the same ranges the downstream crouter viewer keys on to detect
26
+ # render *success*. An exotic/unrecognized diagram type has no renderer at
27
+ # all, so its degradation lives here rather than in a shared renderer module;
28
+ # it must uphold the identical contract — no such glyph survives the echo,
29
+ # even one present verbatim in the source itself.
30
+ _GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
31
+
32
+
33
+ def _first_line_type(source: str) -> str:
34
+ """Return the lowercased first real line's leading keyword.
35
+
36
+ This is the dispatch key: mermaid identifies a diagram's type from its
37
+ first line (``graph``/``flowchart``, ``pie``, ``gantt``, ``sequenceDiagram``,
38
+ ``classDiagram``, ``stateDiagram``, ``erDiagram``, …), after skipping any
39
+ prelude (blank lines, ``%%`` comments/directives, ``---`` YAML
40
+ frontmatter — see ``mermaid_prelude.py``). Returns ``""`` for a source
41
+ with no real content past its prelude.
42
+ """
43
+ lines = strip_prelude_lines(source.splitlines())
44
+ first = next((l.strip() for l in lines if l.strip()), "")
45
+ return first.lower()
46
+
47
+
48
+ def _stripped(source: str) -> str:
49
+ """Return ``source`` with its leading prelude (comments/directives/
50
+ frontmatter) removed, for native renderers that don't otherwise skip it.
51
+ """
52
+ return "\n".join(strip_prelude_lines(source.splitlines()))
53
+
54
+
55
+ def _raw_echo(source: str) -> list[str]:
56
+ """Degrade an exotic/unrecognized diagram type to its raw source lines.
57
+
58
+ No renderer exists for these types (sankey, C4, gitgraph, block, packet,
59
+ kanban, quadrantChart, …, and anything unrecognized): the source is
60
+ echoed back verbatim, with box-drawing/geometric glyphs neutralized so
61
+ the crouter viewer's "no glyphs survived" success check can't be fooled
62
+ by one present in the raw source itself — the same guarantee every
63
+ native renderer's own raw-echo path upholds.
64
+ """
65
+ return [
66
+ _GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()
67
+ ]
68
+
69
+
70
+ def render_mermaid_lines(source: str, width: int) -> list[str]:
71
+ """Dispatch a mermaid source to its type's renderer and return raw lines.
72
+
73
+ Every diagram type mermaid supports that termrender renders has a
74
+ dedicated native Python renderer: ``pie``, ``gantt``, ``sequenceDiagram``,
75
+ ``mindmap``, ``journey``, ``timeline``, ``graph``/``flowchart``,
76
+ ``classDiagram``, ``stateDiagram``/``stateDiagram-v2``, and ``erDiagram``.
77
+ Any other type (sankey, C4, gitgraph, block, packet, kanban, …, or
78
+ anything unrecognized) degrades to a raw echo of the source with no
79
+ box-drawing glyphs — see :func:`_raw_echo`. Lines are returned unpadded;
80
+ callers apply width padding uniformly.
81
+ """
82
+ diagram_type = _first_line_type(source)
83
+ if diagram_type.startswith("pie"):
84
+ return mermaid_pie.render(source, width)
85
+ if diagram_type.startswith("gantt"):
86
+ return mermaid_gantt.render(source, width)
87
+ if diagram_type.startswith("sequencediagram"):
88
+ return mermaid_sequence.render_sequence(source, width)
89
+ if diagram_type.startswith("mindmap"):
90
+ return mermaid_mindmap.render(_stripped(source), width)
91
+ if diagram_type.startswith("journey"):
92
+ return mermaid_journey.render(_stripped(source), width)
93
+ if diagram_type.startswith("timeline"):
94
+ return mermaid_timeline.render(_stripped(source), width)
95
+ if diagram_type.startswith(("graph", "flowchart")):
96
+ return mermaid_flow.render_flowchart(source, width)
97
+ if diagram_type.startswith("classdiagram"):
98
+ return mermaid_class.render_class(source, width)
99
+ if diagram_type.startswith("statediagram"):
100
+ return mermaid_state.render_state(source, width)
101
+ if diagram_type.startswith("erdiagram"):
102
+ return mermaid_er.render_er(source, width)
103
+ return _raw_echo(source)
104
+
105
+
106
+ def render(block: Block, color: bool) -> list[str]:
107
+ """Render a mermaid diagram from pre-rendered or on-the-fly ASCII output."""
108
+ w = block.width
109
+ rendered = block.attrs.get("_rendered")
110
+
111
+ if rendered is None:
112
+ source = block.attrs.get("source", "")
113
+ raw_lines = render_mermaid_lines(source, w or 60)
114
+ else:
115
+ raw_lines = rendered.split("\n")
116
+
117
+ return [visual_ljust(raw_line, w) for raw_line in raw_lines]
@@ -831,24 +831,45 @@ _GAP_X = 3 # minimum border-to-border gap between boxes in one rank/ban
831
831
  _ROW_GAP = 2 # minimum gap between rank bands
832
832
  _COMPONENT_GUTTER = 4 # gap between independently-laid-out components
833
833
  _LABEL_GAP_PAD = 1 # cells of breathing room either side of a label's own text
834
- # when it forces a rank-band gap wider than _ROW_GAP.
834
+ # when it forces a rank-band gap wider than _ROW_GAP
835
+ # along the axis the label's text actually runs.
836
+ _LABELED_ROW_GAP = 3 # inter-rank gap when a label sits on that band's vertical
837
+ # run in *final* screen space (TB/BT): one blank row, the
838
+ # label's own row, one more blank row. A constant, not
839
+ # scaled by the label's text length — the label reads
840
+ # horizontally on a single row regardless of how many
841
+ # characters it has, so the row *count* needed is fixed.
835
842
 
836
843
 
837
844
  def _rank_gap_overrides(
838
- edges: list[FlowEdge], rank_of: dict[str, int]
845
+ edges: list[FlowEdge], rank_of: dict[str, int], direction: Direction
839
846
  ) -> dict[int, int]:
840
847
  """Minimum inter-rank-band gap keyed by the *lower* rank of each
841
848
  adjacent-rank transition, widened past ``_ROW_GAP`` wherever a labeled
842
849
  edge directly connects that transition's two ranks. A forward edge
843
850
  between adjacent ranks routes as a single straight run (or a Z path
844
851
  that still crosses the same inter-rank band) whose only clear space is
845
- this gap — at the base ``_ROW_GAP`` a label wider than a couple of
846
- cells has nowhere to go but onto the boxes it connects, which is
847
- exactly the short LR/adjacent-rank clipped-label bug this closes.
852
+ this gap.
853
+
854
+ The rank axis is native rows here, pre-direction-transform (see the
855
+ adapter docstring) — whether that ends up as *vertical* or
856
+ *horizontal* space on screen depends on ``direction``. For LR/RL the
857
+ transpose turns this band into the final horizontal run the label
858
+ reads along, so it genuinely needs ``visual_len(label)`` cells of
859
+ width — at the base ``_ROW_GAP`` a label wider than a couple of cells
860
+ has nowhere to go but onto the boxes it connects (the short
861
+ LR/adjacent-rank clipped-label bug this closes). For TB/BT the band
862
+ stays vertical on screen and the label is drawn horizontally *across*
863
+ the connector column (:func:`_draw_label_on_segment`'s vertical-segment
864
+ branch) — it needs a little row headroom to have its own clear row,
865
+ not a row per character, so it gets the small constant
866
+ ``_LABELED_ROW_GAP`` regardless of the label's length.
867
+
848
868
  Non-adjacent-rank edges (back-edges, multi-rank spans) don't constrain
849
869
  the gap here — they route through their own side lane, not this band.
850
870
  """
851
871
  overrides: dict[int, int] = {}
872
+ horizontal_on_screen = direction in (Direction.LR, Direction.RL)
852
873
  for e in edges:
853
874
  if e.src == e.dst or not e.label:
854
875
  continue
@@ -859,7 +880,10 @@ def _rank_gap_overrides(
859
880
  lo_r, hi_r = (r_src, r_dst) if r_src <= r_dst else (r_dst, r_src)
860
881
  if hi_r - lo_r != 1:
861
882
  continue
862
- needed = visual_len(e.label) + 2 * _LABEL_GAP_PAD
883
+ if horizontal_on_screen:
884
+ needed = visual_len(e.label) + 2 * _LABEL_GAP_PAD
885
+ else:
886
+ needed = _LABELED_ROW_GAP
863
887
  overrides[lo_r] = max(overrides.get(lo_r, _ROW_GAP), needed)
864
888
  return overrides
865
889
 
@@ -922,7 +946,7 @@ def _place_nodes(
922
946
  ranks[sug.grx[v].rank].append(v)
923
947
 
924
948
  rank_of = {v.data: sug.grx[v].rank for v in comp_vertices}
925
- gap_overrides = _rank_gap_overrides(edges, rank_of)
949
+ gap_overrides = _rank_gap_overrides(edges, rank_of, direction)
926
950
 
927
951
  band_top_for_rank: dict[int, int] = {}
928
952
  cursor = 0