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.
Files changed (83) hide show
  1. {termrender-4.9.0 → termrender-4.9.1}/CHANGELOG.md +43 -0
  2. {termrender-4.9.0 → termrender-4.9.1}/PKG-INFO +1 -1
  3. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_class.py +0 -13
  4. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_layout.py +286 -21
  5. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_class.py +13 -0
  6. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_corpus.py +30 -0
  7. {termrender-4.9.0 → termrender-4.9.1}/.github/workflows/publish.yml +0 -0
  8. {termrender-4.9.0 → termrender-4.9.1}/.gitignore +0 -0
  9. {termrender-4.9.0 → termrender-4.9.1}/CLAUDE.md +0 -0
  10. {termrender-4.9.0 → termrender-4.9.1}/LICENSE +0 -0
  11. {termrender-4.9.0 → termrender-4.9.1}/README.md +0 -0
  12. {termrender-4.9.0 → termrender-4.9.1}/design.json +0 -0
  13. {termrender-4.9.0 → termrender-4.9.1}/pyproject.toml +0 -0
  14. {termrender-4.9.0 → termrender-4.9.1}/requirements.json +0 -0
  15. {termrender-4.9.0 → termrender-4.9.1}/scripts/build-mermaid-ascii.sh +0 -0
  16. {termrender-4.9.0 → termrender-4.9.1}/scripts/mermaid_flow_parity.py +0 -0
  17. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/CLAUDE.md +0 -0
  18. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/__init__.py +0 -0
  19. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/__main__.py +0 -0
  20. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  21. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/_mermaid_bin.py +0 -0
  22. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/blocks.py +0 -0
  23. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/emit.py +0 -0
  24. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/layout.py +0 -0
  25. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/parser.py +0 -0
  26. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/py.typed +0 -0
  27. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/CLAUDE.md +0 -0
  28. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/__init__.py +0 -0
  29. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/borders.py +0 -0
  30. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/charts.py +0 -0
  31. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/code.py +0 -0
  32. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/columns.py +0 -0
  33. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/diff.py +0 -0
  34. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/divider.py +0 -0
  35. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid.py +0 -0
  36. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_er.py +0 -0
  37. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow.py +0 -0
  38. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  39. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  40. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_gantt.py +0 -0
  41. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_journey.py +0 -0
  42. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  43. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_pie.py +0 -0
  44. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
  45. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
  46. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_state.py +0 -0
  47. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
  48. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/panel.py +0 -0
  49. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/quote.py +0 -0
  50. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/stat.py +0 -0
  51. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/table.py +0 -0
  52. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/text.py +0 -0
  53. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/timeline.py +0 -0
  54. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/renderers/tree.py +0 -0
  55. {termrender-4.9.0 → termrender-4.9.1}/src/termrender/style.py +0 -0
  56. {termrender-4.9.0 → termrender-4.9.1}/tests/__init__.py +0 -0
  57. {termrender-4.9.0 → termrender-4.9.1}/tests/test_charts.py +0 -0
  58. {termrender-4.9.0 → termrender-4.9.1}/tests/test_cli_contract.py +0 -0
  59. {termrender-4.9.0 → termrender-4.9.1}/tests/test_column_alignment.py +0 -0
  60. {termrender-4.9.0 → termrender-4.9.1}/tests/test_diff.py +0 -0
  61. {termrender-4.9.0 → termrender-4.9.1}/tests/test_inline_badge.py +0 -0
  62. {termrender-4.9.0 → termrender-4.9.1}/tests/test_linebreak.py +0 -0
  63. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_compat.py +0 -0
  64. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_dispatch.py +0 -0
  65. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_er.py +0 -0
  66. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow.py +0 -0
  67. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_layout.py +0 -0
  68. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_model.py +0 -0
  69. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_parser.py +0 -0
  70. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_flow_shapes.py +0 -0
  71. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_gantt.py +0 -0
  72. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_journey.py +0 -0
  73. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_mindmap.py +0 -0
  74. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_pie.py +0 -0
  75. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_sequence.py +0 -0
  76. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_state.py +0 -0
  77. {termrender-4.9.0 → termrender-4.9.1}/tests/test_mermaid_timeline.py +0 -0
  78. {termrender-4.9.0 → termrender-4.9.1}/tests/test_myst_gaps.py +0 -0
  79. {termrender-4.9.0 → termrender-4.9.1}/tests/test_stat.py +0 -0
  80. {termrender-4.9.0 → termrender-4.9.1}/tests/test_tasklist.py +0 -0
  81. {termrender-4.9.0 → termrender-4.9.1}/tests/test_timeline.py +0 -0
  82. {termrender-4.9.0 → termrender-4.9.1}/tests/test_variable_colons.py +0 -0
  83. {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.0
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
- return max(segments, key=lambda seg: _segment_length(*seg))
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
- src_extent = _rank_extent(direction, src_rect)
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 same_rank:
1818
+ if kind == "same-rank":
1554
1819
  return [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
1555
1820
 
1556
- src_center = (src_extent[0] + src_extent[1]) / 2
1557
- dst_center = (dst_extent[0] + dst_extent[1]) / 2
1558
- forward = (dst_center > src_center) == _forward_increasing(direction)
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(rects, g.direction, e, lane_counter, self_loop_counter)
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