termrender 4.9.0__tar.gz → 4.9.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.
- {termrender-4.9.0 → termrender-4.9.1}/CHANGELOG.md +43 -0
- {termrender-4.9.0 → termrender-4.9.1}/PKG-INFO +1 -1
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_class.py +0 -13
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_layout.py +286 -21
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_class.py +13 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_corpus.py +30 -0
- {termrender-4.9.0 → termrender-4.9.1}/.github/workflows/publish.yml +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/.gitignore +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/CLAUDE.md +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/LICENSE +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/README.md +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/design.json +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/pyproject.toml +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/requirements.json +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/scripts/mermaid_flow_parity.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/__init__.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/__main__.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/blocks.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/emit.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/layout.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/parser.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/py.typed +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/code.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_er.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_state.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/table.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/text.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/src/termrender/style.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/__init__.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_charts.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_cli_contract.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_column_alignment.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_diff.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_inline_badge.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_linebreak.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_er.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_layout.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_state.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_myst_gaps.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_stat.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_tasklist.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_timeline.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/tests/test_variable_colons.py +0 -0
- {termrender-4.9.0 → termrender-4.9.1}/uv.lock +0 -0
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.9.1 (2026-07-07)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **mermaid-flow**: Stop dropping edge labels/markers on a shared exit anchor
|
|
9
|
+
([`552d3d3`](https://github.com/crouton-labs/termrender/commit/552d3d3deef061e23c5b894852ae024065366eb8))
|
|
10
|
+
|
|
11
|
+
When 2+ edges leave one node through the router's single fixed exit anchor (e.g. two forward edges
|
|
12
|
+
in LR/RL, or a diamond node's right side), two defects followed:
|
|
13
|
+
|
|
14
|
+
- Label drop: the shared first segment of each edge's Z-path could tie in length with each edge's
|
|
15
|
+
own distinguishing segment further along - routine in LR/RL, where a node's fan-out run and its
|
|
16
|
+
branch runs often measure the same few cells. _longest_segment's old first-wins tie-break always
|
|
17
|
+
picked that shared segment for both edges' labels, so the second label landed on cells the first
|
|
18
|
+
already claimed and silently vanished. Fixed by breaking ties toward the *last* segment instead -
|
|
19
|
+
the segment nearest each edge's own destination, which is never shared. TB is unaffected (its
|
|
20
|
+
branch runs are typically much longer than the shared trunk, so no tie exists there to begin
|
|
21
|
+
with).
|
|
22
|
+
|
|
23
|
+
- Marker drop: a source-side arrow-kind marker glyph (UML composition and aggregation diamonds)
|
|
24
|
+
drawn at the shared anchor cell survived only for the last-drawn edge. Fixed via a new pre-pass,
|
|
25
|
+
_allocate_edge_anchors, that spreads a group's exit points along the node's side - but only when
|
|
26
|
+
2+ of that group's edges actually carry a source-side marker. Plain, markerless fan-outs (by far
|
|
27
|
+
the common case) keep sharing one exit cell, since that's what makes draw_segment's junction
|
|
28
|
+
bitmask resolve into a single clean tee rather than two disjoint stubs - spreading those would
|
|
29
|
+
only change where the fan visually splits, not fix anything, and would break the existing
|
|
30
|
+
trunk-then-tee golden output for every unlabeled fan-out/merge case.
|
|
31
|
+
|
|
32
|
+
Anchor spreading is shape-aware for NodeShape.DIAMOND (the only shape whose left/right sides aren't
|
|
33
|
+
straight across their full bounding-rect span) so a spread point never lands in the diamond's
|
|
34
|
+
tapered corner region.
|
|
35
|
+
|
|
36
|
+
Removes the "known limitation" paragraph from mermaid_class.py's docstring now that the
|
|
37
|
+
composition/aggregation marker case it described is fixed.
|
|
38
|
+
|
|
39
|
+
Goldens touched: added test_lr_fan_out_with_labels_both_present_golden (new - pins the fixed LR
|
|
40
|
+
output, previously buggy/missing) and test_two_source_side_markers_from_one_class_both_survive
|
|
41
|
+
(new). No existing golden was changed - the fan-out/merge goldens (test_fan_out_golden,
|
|
42
|
+
test_multi_parent_dag_golden, test_td_direction_golden, test_lr_direction_golden) are
|
|
43
|
+
byte-for-byte unchanged because none of their edges carry a source-side marker, so the new
|
|
44
|
+
anchor-spread never triggers for them.
|
|
45
|
+
|
|
46
|
+
|
|
4
47
|
## v4.9.0 (2026-07-07)
|
|
5
48
|
|
|
6
49
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.9.
|
|
3
|
+
Version: 4.9.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
|
|
@@ -70,19 +70,6 @@ Known degradations (by design, not bugs)
|
|
|
70
70
|
- ``note`` statements, `<<...>>` generic constraints beyond the ``~T~``
|
|
71
71
|
substitution, and class-diagram namespaces are not recognized — lines
|
|
72
72
|
using them are silently dropped (best-effort parsing, never raised).
|
|
73
|
-
- A relation's *source*-side marker (inheritance/composition/aggregation's
|
|
74
|
-
glyph on the "whole"/parent class) is drawn at that class's single
|
|
75
|
-
shared exit anchor for its rank-flow direction. When one class has
|
|
76
|
-
several outgoing relations that each carry a source-side marker (e.g. a
|
|
77
|
-
class that both composes one collaborator and aggregates another), all
|
|
78
|
-
of them exit through that same anchor cell and only the last-drawn
|
|
79
|
-
marker glyph survives there — topology and line routing stay correct
|
|
80
|
-
for every edge, but the specific marker glyph shown at that shared point
|
|
81
|
-
reflects only one of the colliding relations. Rare in small diagrams
|
|
82
|
-
(most classes have at most one source-marked outgoing relation);
|
|
83
|
-
accepted the same way the engine already accepts dense-graph line
|
|
84
|
-
crossings, rather than reworking shared anchor allocation across the
|
|
85
|
-
underlying flowchart engine.
|
|
86
73
|
- See ``mermaid_flow_layout.py``'s docstring for the inherited engine
|
|
87
74
|
degradations (dense-graph crossings, CJK wrap width, minimum-box-size
|
|
88
75
|
cosmetics) — this renderer's output goes through the same rasterizer and
|
|
@@ -1297,6 +1297,244 @@ def _lane_anchor(direction: Direction, rect: BoxRect) -> tuple[int, int]:
|
|
|
1297
1297
|
return rect.bottom_mid if _rank_is_horizontal(direction) else rect.right_mid
|
|
1298
1298
|
|
|
1299
1299
|
|
|
1300
|
+
# --- shared-anchor fan-out (multiple edges through one node's side) ---
|
|
1301
|
+
#
|
|
1302
|
+
# The four anchor functions above always return the *same* single point for
|
|
1303
|
+
# a given (direction, rect) — correct for the common case of one edge per
|
|
1304
|
+
# side, but when 2+ edges share a node's exit or entry side (e.g. two
|
|
1305
|
+
# forward edges leaving the same node), every one of them would otherwise
|
|
1306
|
+
# start/end its path on the exact same cell. Two visible bugs follow: a
|
|
1307
|
+
# src-side arrow-kind marker glyph landing there survives only for the
|
|
1308
|
+
# last-drawn edge (see mermaid_class.py's UML composition/aggregation
|
|
1309
|
+
# case), and — whenever the shared first/last segment happens to tie in
|
|
1310
|
+
# length with each edge's own distinguishing segment (routine in LR/RL,
|
|
1311
|
+
# where a node's fan-out run and its branch runs can all measure the same
|
|
1312
|
+
# few cells) — :func:`_longest_segment`'s tie-break picks the *shared*
|
|
1313
|
+
# segment for both edges' labels, so a second label lands on cells the
|
|
1314
|
+
# first already claimed and is silently dropped. ``_allocate_edge_anchors``
|
|
1315
|
+
# is a pre-pass that spreads such shared anchors along the side's usable
|
|
1316
|
+
# span; groups of size 1 (the overwhelming majority of edges) are left
|
|
1317
|
+
# alone entirely (no override), so every existing single-edge-per-side
|
|
1318
|
+
# render is byte-for-byte unchanged.
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _forward_exit_side(direction: Direction) -> str:
|
|
1322
|
+
"""Side name matching :func:`_forward_exit` for ``direction`` — a
|
|
1323
|
+
node's forward exit side is the same physical side for every node in
|
|
1324
|
+
the graph (it depends only on direction), which is exactly what makes
|
|
1325
|
+
grouping by ``(node_id, side)`` meaningful."""
|
|
1326
|
+
horiz = _rank_is_horizontal(direction)
|
|
1327
|
+
forward = _forward_increasing(direction)
|
|
1328
|
+
if horiz:
|
|
1329
|
+
return "right" if forward else "left"
|
|
1330
|
+
return "bottom" if forward else "top"
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _forward_entry_side(direction: Direction) -> str:
|
|
1334
|
+
"""Side name matching :func:`_forward_entry` — the mirror image of
|
|
1335
|
+
:func:`_forward_exit_side`."""
|
|
1336
|
+
horiz = _rank_is_horizontal(direction)
|
|
1337
|
+
forward = _forward_increasing(direction)
|
|
1338
|
+
if horiz:
|
|
1339
|
+
return "left" if forward else "right"
|
|
1340
|
+
return "top" if forward else "bottom"
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _lane_side(direction: Direction) -> str:
|
|
1344
|
+
"""Side name matching :func:`_lane_anchor`."""
|
|
1345
|
+
return "bottom" if _rank_is_horizontal(direction) else "right"
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _diamond_straight_span(rect: BoxRect, label: str) -> tuple[int, int]:
|
|
1349
|
+
"""A ``NodeShape.DIAMOND``'s left/right sides are only straight (``│``)
|
|
1350
|
+
across its flat label band, between the two tapered corners — mirrors
|
|
1351
|
+
:meth:`Canvas._draw_diamond`'s own taper calculation exactly (kept in
|
|
1352
|
+
sync deliberately: this is the sizing-time read of that drawing-time
|
|
1353
|
+
geometry) so a spread anchor never lands in the taper region, where
|
|
1354
|
+
the ``│`` a plain rect would have is instead a blank cell just outside
|
|
1355
|
+
the diamond's slanted outline (or the slant glyph itself) — either way
|
|
1356
|
+
a line touching down there reads as visually detached from the shape.
|
|
1357
|
+
Collapses to a single point (``lo == hi``) for a diamond too small to
|
|
1358
|
+
have more than one straight row, exactly reproducing the un-spread
|
|
1359
|
+
single-anchor point in that case."""
|
|
1360
|
+
x, y, w, h = rect.x, rect.y, rect.w, rect.h
|
|
1361
|
+
if w < 5 or h < 3:
|
|
1362
|
+
return y + h // 2, y + h // 2
|
|
1363
|
+
max_taper = max(1, (w - 1) // 2)
|
|
1364
|
+
content_lines = wrap_text(label or "", max(w - 4, 1)) or [""]
|
|
1365
|
+
taper = max(1, (h - len(content_lines)) // 2)
|
|
1366
|
+
taper = min(taper, max_taper, (h - 1) // 2 or 1)
|
|
1367
|
+
if h - 2 * taper < 1:
|
|
1368
|
+
taper = max(1, (h - 1) // 2)
|
|
1369
|
+
lo, hi = y + taper, y + h - taper - 1
|
|
1370
|
+
if lo > hi:
|
|
1371
|
+
lo = hi = y + h // 2
|
|
1372
|
+
return lo, hi
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _side_span_for_node(
|
|
1376
|
+
node: "FlowNode | None", rect: BoxRect, side: str
|
|
1377
|
+
) -> tuple[int, int]:
|
|
1378
|
+
"""Usable ``[lo, hi]`` anchor span along one border side of a placed
|
|
1379
|
+
node — shape-aware only where a shape's straight border cells don't
|
|
1380
|
+
already span the full bounding-rect side (currently just
|
|
1381
|
+
``NodeShape.DIAMOND``; every other shape's visible border decoration
|
|
1382
|
+
lives entirely within the bounding-rect span :func:`_side_interior_span`
|
|
1383
|
+
already describes, including shapes whose glyphs aren't literally
|
|
1384
|
+
straight everywhere, e.g. the hexagon's corner cut or the
|
|
1385
|
+
parallelogram's skew — those already anchor at the plain bounding-rect
|
|
1386
|
+
mid in the single-edge case, so reusing that same span here keeps the
|
|
1387
|
+
multi-edge case consistent with it rather than introducing a second,
|
|
1388
|
+
stricter geometry only this function knows about). A diamond's
|
|
1389
|
+
top/bottom sides are a single tip point, not a span — collapsed to the
|
|
1390
|
+
bounding-rect mid column/row, same as :func:`_forward_exit` already
|
|
1391
|
+
anchors there.
|
|
1392
|
+
"""
|
|
1393
|
+
if node is not None and node.compartments is None and node.shape is NodeShape.DIAMOND:
|
|
1394
|
+
if side in ("left", "right"):
|
|
1395
|
+
return _diamond_straight_span(rect, node.label)
|
|
1396
|
+
mid = rect.x + rect.w // 2
|
|
1397
|
+
return mid, mid
|
|
1398
|
+
return _side_interior_span(rect, side)
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _side_interior_span(rect: BoxRect, side: str) -> tuple[int, int]:
|
|
1402
|
+
"""Usable ``[lo, hi]`` span (inclusive) along one border side of
|
|
1403
|
+
``rect``, corners excluded when the side is long enough to spare them
|
|
1404
|
+
(falls back to the full span at minimum box size, where the interior
|
|
1405
|
+
excluding corners would otherwise be empty). This is deliberately a
|
|
1406
|
+
*different* (slightly narrower) span than the single-anchor
|
|
1407
|
+
``top_mid``/``bottom_mid``/``left_mid``/``right_mid`` properties use —
|
|
1408
|
+
only :func:`_allocate_edge_anchors` (the 2+-edges-per-side case)
|
|
1409
|
+
consults this, so the single-edge case's existing anchor point (and
|
|
1410
|
+
every golden built on it) is untouched."""
|
|
1411
|
+
if side in ("top", "bottom"):
|
|
1412
|
+
lo, hi = rect.x + 1, rect.x + rect.w - 2
|
|
1413
|
+
if lo > hi:
|
|
1414
|
+
lo, hi = rect.x, rect.x + rect.w - 1
|
|
1415
|
+
return lo, hi
|
|
1416
|
+
lo, hi = rect.y + 1, rect.y + rect.h - 2
|
|
1417
|
+
if lo > hi:
|
|
1418
|
+
lo, hi = rect.y, rect.y + rect.h - 1
|
|
1419
|
+
return lo, hi
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _side_point(rect: BoxRect, side: str, along: int) -> tuple[int, int]:
|
|
1423
|
+
"""A border point on ``rect``'s ``side`` at the given along-the-side
|
|
1424
|
+
coordinate (from :func:`_side_interior_span`/:func:`_spread_points`)."""
|
|
1425
|
+
if side == "top":
|
|
1426
|
+
return (along, rect.y)
|
|
1427
|
+
if side == "bottom":
|
|
1428
|
+
return (along, rect.y + rect.h - 1)
|
|
1429
|
+
if side == "left":
|
|
1430
|
+
return (rect.x, along)
|
|
1431
|
+
return (rect.x + rect.w - 1, along) # "right"
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _spread_points(lo: int, hi: int, n: int) -> list[int]:
|
|
1435
|
+
"""``n`` coordinates evenly spread across ``[lo, hi]`` inclusive (the
|
|
1436
|
+
first at ``lo``, the last at ``hi``) — callers only ever invoke this
|
|
1437
|
+
for ``n >= 2``."""
|
|
1438
|
+
if n <= 1:
|
|
1439
|
+
return [(lo + hi) // 2]
|
|
1440
|
+
span = hi - lo
|
|
1441
|
+
return [lo + round(i * span / (n - 1)) for i in range(n)]
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _classify_edge(
|
|
1445
|
+
rects: dict[str, BoxRect], direction: Direction, edge: FlowEdge
|
|
1446
|
+
) -> str | None:
|
|
1447
|
+
"""One of ``"same-rank"``, ``"forward"``, ``"back"``, or ``None``
|
|
1448
|
+
(dangling node-id reference — the parser guarantees valid endpoints,
|
|
1449
|
+
but this module never crashes on one). Shared by
|
|
1450
|
+
:func:`_allocate_edge_anchors` and :func:`_route_edge_path` so the two
|
|
1451
|
+
never disagree about which routing branch an edge takes."""
|
|
1452
|
+
src_rect = rects.get(edge.src)
|
|
1453
|
+
dst_rect = rects.get(edge.dst)
|
|
1454
|
+
if src_rect is None or dst_rect is None:
|
|
1455
|
+
return None
|
|
1456
|
+
src_extent = _rank_extent(direction, src_rect)
|
|
1457
|
+
dst_extent = _rank_extent(direction, dst_rect)
|
|
1458
|
+
same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
|
|
1459
|
+
if same_rank:
|
|
1460
|
+
return "same-rank"
|
|
1461
|
+
src_center = (src_extent[0] + src_extent[1]) / 2
|
|
1462
|
+
dst_center = (dst_extent[0] + dst_extent[1]) / 2
|
|
1463
|
+
forward = (dst_center > src_center) == _forward_increasing(direction)
|
|
1464
|
+
return "forward" if forward else "back"
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _allocate_edge_anchors(
|
|
1468
|
+
rects: dict[str, BoxRect],
|
|
1469
|
+
direction: Direction,
|
|
1470
|
+
edges: list[FlowEdge],
|
|
1471
|
+
nodes: list[FlowNode] | None = None,
|
|
1472
|
+
) -> dict[int, tuple[int, int]]:
|
|
1473
|
+
"""Pre-pass over every edge: group forward/back edges by the exit side
|
|
1474
|
+
they share with sibling edges leaving the *same* node (``(src, exit
|
|
1475
|
+
side)`` for forward edges, ``(src, lane side)`` for back-edges), and
|
|
1476
|
+
spread a group's anchor points along that side's usable span (see
|
|
1477
|
+
:func:`_side_interior_span`) — but **only** when 2+ of that group's
|
|
1478
|
+
edges carry a source-side arrow marker (``src_arrow`` or a non-default
|
|
1479
|
+
``src_arrow_kind`` — the UML composition/aggregation case documented
|
|
1480
|
+
in ``mermaid_class.py``). Left stacked on one shared anchor otherwise
|
|
1481
|
+
(the overwhelming majority of groups): a plain, markerless multi-edge
|
|
1482
|
+
fan-out (``A-->B; A-->C``) *relies* on sharing one exit cell for its
|
|
1483
|
+
trunk-then-tee look (draw_segment's junction bitmask resolves the
|
|
1484
|
+
shared point into a single ┬/┴, not two disjoint stubs) — spreading it
|
|
1485
|
+
would only change this module's own aesthetic choice of where the fan
|
|
1486
|
+
visually splits, not fix anything, and would break that look for every
|
|
1487
|
+
existing fan-out/merge golden. A marker glyph, unlike a plain line
|
|
1488
|
+
junction, has no such union — two different marker glyphs landing on
|
|
1489
|
+
one cell just silently lose all but the last-drawn one, which is the
|
|
1490
|
+
actual defect this closes. (The sibling label-collision defect — two
|
|
1491
|
+
edges from a shared exit tying for "longest straight run" so a second
|
|
1492
|
+
label lands on cells the first already claimed — is fixed separately,
|
|
1493
|
+
in :func:`_longest_segment`'s tie-break, since it needs no anchor
|
|
1494
|
+
change at all.) Returns a dict keyed by index into ``edges``; an edge
|
|
1495
|
+
absent from it keeps the engine's single fixed exit anchor
|
|
1496
|
+
(:func:`_forward_exit`/:func:`_lane_anchor`) unchanged. Same-rank
|
|
1497
|
+
edges and self-loops are never grouped here — same-rank anchors are
|
|
1498
|
+
already per-pair (:func:`_facing_anchor`, dynamic per destination, not
|
|
1499
|
+
a fixed shared side), and self-loops already stack via their own
|
|
1500
|
+
``self_loop_counter``-driven reach.
|
|
1501
|
+
"""
|
|
1502
|
+
node_by_id = {n.id: n for n in (nodes or [])}
|
|
1503
|
+
groups: dict[tuple[str, str], list[int]] = defaultdict(list)
|
|
1504
|
+
for i, e in enumerate(edges):
|
|
1505
|
+
if e.src == e.dst:
|
|
1506
|
+
continue
|
|
1507
|
+
kind = _classify_edge(rects, direction, e)
|
|
1508
|
+
if kind == "forward":
|
|
1509
|
+
groups[(e.src, _forward_exit_side(direction))].append(i)
|
|
1510
|
+
elif kind == "back":
|
|
1511
|
+
groups[(e.src, _lane_side(direction))].append(i)
|
|
1512
|
+
# "same-rank" / None (dangling): no override, unchanged behavior.
|
|
1513
|
+
|
|
1514
|
+
overrides: dict[int, tuple[int, int]] = {}
|
|
1515
|
+
for (node_id, side), idxs in groups.items():
|
|
1516
|
+
if len(idxs) < 2:
|
|
1517
|
+
continue
|
|
1518
|
+
marked = [
|
|
1519
|
+
i
|
|
1520
|
+
for i in idxs
|
|
1521
|
+
if edges[i].src_arrow or edges[i].src_arrow_kind != "default"
|
|
1522
|
+
]
|
|
1523
|
+
if len(marked) < 2:
|
|
1524
|
+
continue
|
|
1525
|
+
rect = rects.get(node_id)
|
|
1526
|
+
if rect is None:
|
|
1527
|
+
continue
|
|
1528
|
+
ordered = sorted(
|
|
1529
|
+
idxs, key=lambda i: _abstract(direction, rects[edges[i].dst].center)[1]
|
|
1530
|
+
)
|
|
1531
|
+
lo, hi = _side_span_for_node(node_by_id.get(node_id), rect, side)
|
|
1532
|
+
spread = _spread_points(lo, hi, len(ordered))
|
|
1533
|
+
for idx, along in zip(ordered, spread):
|
|
1534
|
+
overrides[idx] = _side_point(rect, side, along)
|
|
1535
|
+
return overrides
|
|
1536
|
+
|
|
1537
|
+
|
|
1300
1538
|
def _abstract(direction: Direction, point: tuple[int, int]) -> tuple[int, int]:
|
|
1301
1539
|
"""``point`` decomposed into (primary, secondary) = (rank-axis coord,
|
|
1302
1540
|
off-axis coord) — the coordinate frame the path-shape helpers reason
|
|
@@ -1363,11 +1601,30 @@ def _longest_segment(
|
|
|
1363
1601
|
points: list[tuple[int, int]],
|
|
1364
1602
|
) -> tuple[tuple[int, int], tuple[int, int]] | None:
|
|
1365
1603
|
"""The longest straight run in a polyline — where an edge label is
|
|
1366
|
-
centered.
|
|
1604
|
+
centered. Ties break toward the *last* (furthest downstream) segment
|
|
1605
|
+
rather than the first. This matters specifically for a forward Z-path
|
|
1606
|
+
out of a node with 2+ outgoing edges: its first segment is the shared
|
|
1607
|
+
exit-side run common to every sibling edge leaving that node (see
|
|
1608
|
+
:func:`_allocate_edge_anchors`'s docstring), so on a tie it is the one
|
|
1609
|
+
segment guaranteed to collide with a sibling's own label. Preferring
|
|
1610
|
+
the last segment picks each edge's own distinguishing run near its
|
|
1611
|
+
destination instead — routine in LR/RL, where a node's shared fan-out
|
|
1612
|
+
run and its per-branch runs often measure the same few cells (TB
|
|
1613
|
+
rarely ties here at all: its branch runs are typically much longer
|
|
1614
|
+
than the shared trunk, so this tie-break is moot there and every
|
|
1615
|
+
existing TB/back-edge/self-loop golden is unaffected — see this
|
|
1616
|
+
module's test suite for the confirming corpus)."""
|
|
1367
1617
|
segments = list(zip(points, points[1:]))
|
|
1368
1618
|
if not segments:
|
|
1369
1619
|
return None
|
|
1370
|
-
|
|
1620
|
+
best = segments[0]
|
|
1621
|
+
best_len = _segment_length(*best)
|
|
1622
|
+
for seg in segments[1:]:
|
|
1623
|
+
length = _segment_length(*seg)
|
|
1624
|
+
if length >= best_len:
|
|
1625
|
+
best = seg
|
|
1626
|
+
best_len = length
|
|
1627
|
+
return best
|
|
1371
1628
|
|
|
1372
1629
|
|
|
1373
1630
|
def _label_positions(length: int, center: int, lo: int, hi: int) -> list[int]:
|
|
@@ -1527,6 +1784,7 @@ def _route_edge_path(
|
|
|
1527
1784
|
edge: FlowEdge,
|
|
1528
1785
|
lane_counter: list[int],
|
|
1529
1786
|
self_loop_counter: dict[str, int],
|
|
1787
|
+
exit_anchor: tuple[int, int] | None = None,
|
|
1530
1788
|
) -> list[tuple[int, int]] | None:
|
|
1531
1789
|
"""Compute one edge's polyline points: anchors chosen by relative rank
|
|
1532
1790
|
position, an L/Z/C path shape. Pure path computation only — drawing is
|
|
@@ -1535,6 +1793,15 @@ def _route_edge_path(
|
|
|
1535
1793
|
another edge's already-placed label. Dangling node-id references
|
|
1536
1794
|
return ``None`` (defensive no-op — the parser guarantees valid
|
|
1537
1795
|
endpoints; this module never crashes on one).
|
|
1796
|
+
|
|
1797
|
+
``exit_anchor``, when given (from :func:`_allocate_edge_anchors`),
|
|
1798
|
+
replaces the src border point that would otherwise come from
|
|
1799
|
+
:func:`_forward_exit`/:func:`_lane_anchor` — used only when this
|
|
1800
|
+
edge's exit side is shared with a sibling edge that also carries a
|
|
1801
|
+
source-side arrow marker; ``None`` (the overwhelming common case)
|
|
1802
|
+
reproduces the original single-anchor behavior exactly. Same-rank
|
|
1803
|
+
edges and self-loops ignore it (see :func:`_allocate_edge_anchors`'s
|
|
1804
|
+
docstring for why).
|
|
1538
1805
|
"""
|
|
1539
1806
|
src_rect = rects.get(edge.src)
|
|
1540
1807
|
dst_rect = rects.get(edge.dst)
|
|
@@ -1546,22 +1813,14 @@ def _route_edge_path(
|
|
|
1546
1813
|
self_loop_counter[edge.src] = lane + 1
|
|
1547
1814
|
return _self_loop_points(direction, src_rect, lane)
|
|
1548
1815
|
|
|
1549
|
-
|
|
1550
|
-
dst_extent = _rank_extent(direction, dst_rect)
|
|
1551
|
-
same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
|
|
1816
|
+
kind = _classify_edge(rects, direction, edge)
|
|
1552
1817
|
|
|
1553
|
-
if
|
|
1818
|
+
if kind == "same-rank":
|
|
1554
1819
|
return [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
|
|
1555
1820
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
if forward:
|
|
1560
|
-
return _z_path(
|
|
1561
|
-
direction,
|
|
1562
|
-
_forward_exit(direction, src_rect),
|
|
1563
|
-
_forward_entry(direction, dst_rect),
|
|
1564
|
-
)
|
|
1821
|
+
if kind == "forward":
|
|
1822
|
+
exit_pt = exit_anchor if exit_anchor is not None else _forward_exit(direction, src_rect)
|
|
1823
|
+
return _z_path(direction, exit_pt, _forward_entry(direction, dst_rect))
|
|
1565
1824
|
|
|
1566
1825
|
lane = lane_counter[0]
|
|
1567
1826
|
lane_counter[0] += 1
|
|
@@ -1570,11 +1829,9 @@ def _route_edge_path(
|
|
|
1570
1829
|
+ _LANE_MARGIN
|
|
1571
1830
|
+ lane * _LANE_GAP
|
|
1572
1831
|
)
|
|
1832
|
+
exit_pt = exit_anchor if exit_anchor is not None else _lane_anchor(direction, src_rect)
|
|
1573
1833
|
return _lane_path(
|
|
1574
|
-
direction,
|
|
1575
|
-
_lane_anchor(direction, src_rect),
|
|
1576
|
-
_lane_anchor(direction, dst_rect),
|
|
1577
|
-
lane_secondary,
|
|
1834
|
+
direction, exit_pt, _lane_anchor(direction, dst_rect), lane_secondary
|
|
1578
1835
|
)
|
|
1579
1836
|
|
|
1580
1837
|
|
|
@@ -1652,9 +1909,17 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
|
|
|
1652
1909
|
# a cell — labels are only ever safe once no more lines are coming.
|
|
1653
1910
|
lane_counter = [0]
|
|
1654
1911
|
self_loop_counter: dict[str, int] = {}
|
|
1912
|
+
exit_overrides = _allocate_edge_anchors(rects, g.direction, g.edges, g.nodes)
|
|
1655
1913
|
edge_paths: list[tuple[FlowEdge, list[tuple[int, int]]]] = []
|
|
1656
|
-
for e in g.edges:
|
|
1657
|
-
points = _route_edge_path(
|
|
1914
|
+
for i, e in enumerate(g.edges):
|
|
1915
|
+
points = _route_edge_path(
|
|
1916
|
+
rects,
|
|
1917
|
+
g.direction,
|
|
1918
|
+
e,
|
|
1919
|
+
lane_counter,
|
|
1920
|
+
self_loop_counter,
|
|
1921
|
+
exit_overrides.get(i),
|
|
1922
|
+
)
|
|
1658
1923
|
if points is not None:
|
|
1659
1924
|
edge_paths.append((e, points))
|
|
1660
1925
|
for e, points in edge_paths:
|
|
@@ -167,6 +167,19 @@ def test_aggregation_hollow_diamond_at_owner():
|
|
|
167
167
|
assert any("◇" in line for line in lines)
|
|
168
168
|
|
|
169
169
|
|
|
170
|
+
def test_two_source_side_markers_from_one_class_both_survive():
|
|
171
|
+
# Regression: Whole both composes Part1 and aggregates Part2, so both
|
|
172
|
+
# relations' source-side marker used to land on Whole's one shared
|
|
173
|
+
# exit anchor — only the last-drawn glyph survived there. Both must
|
|
174
|
+
# now be visible (see mermaid_flow_layout.py's _allocate_edge_anchors).
|
|
175
|
+
lines = _lines("classDiagram\nWhole *-- Part1\nWhole o-- Part2\n")
|
|
176
|
+
joined = "\n".join(lines)
|
|
177
|
+
assert "◆" in joined
|
|
178
|
+
assert "◇" in joined
|
|
179
|
+
for name in ("Whole", "Part1", "Part2"):
|
|
180
|
+
assert name in joined
|
|
181
|
+
|
|
182
|
+
|
|
170
183
|
def test_association_filled_arrow_at_target():
|
|
171
184
|
lines = _lines("classDiagram\nDriver --> Car\n")
|
|
172
185
|
assert any("▼" in line for line in lines)
|
|
@@ -360,6 +360,36 @@ def test_lr_direction_golden():
|
|
|
360
360
|
]
|
|
361
361
|
|
|
362
362
|
|
|
363
|
+
# --------------------------------------------------------------------------
|
|
364
|
+
# LR fan-out with 2 labeled edges — both labels must survive (regression:
|
|
365
|
+
# a shared exit anchor used to make the two edges' first segments tie for
|
|
366
|
+
# "longest straight run", so the second label silently overwrote nothing
|
|
367
|
+
# and just never appeared — see mermaid_flow_layout.py's
|
|
368
|
+
# _longest_segment/_allocate_edge_anchors docstrings)
|
|
369
|
+
# --------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_lr_fan_out_with_labels_both_present_golden():
|
|
373
|
+
lines = _lines(
|
|
374
|
+
"flowchart LR\n"
|
|
375
|
+
" A{check} -->|yes| B[Approved]\n"
|
|
376
|
+
" A -->|no| C[Rejected]\n"
|
|
377
|
+
)
|
|
378
|
+
assert lines == [
|
|
379
|
+
" \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510",
|
|
380
|
+
" yes\u25b6 Approved \u2502",
|
|
381
|
+
" \u2571 \u2572 \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518",
|
|
382
|
+
" \u2571 \u2572 \u2502",
|
|
383
|
+
"\u2502 check \u2502\u2500\u2500\u2524",
|
|
384
|
+
" \u2572 \u2571 \u2502",
|
|
385
|
+
" \u2572 \u2571 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510",
|
|
386
|
+
" no\u2500\u25b6 Rejected \u2502",
|
|
387
|
+
" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518",
|
|
388
|
+
]
|
|
389
|
+
text = "\n".join(lines)
|
|
390
|
+
assert "yes" in text and "no" in text
|
|
391
|
+
|
|
392
|
+
|
|
363
393
|
# --------------------------------------------------------------------------
|
|
364
394
|
# Subgraph and nested subgraph
|
|
365
395
|
# --------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|