termrender 4.10.0__tar.gz → 4.10.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {termrender-4.10.0 → termrender-4.10.2}/CHANGELOG.md +32 -0
  2. {termrender-4.10.0 → termrender-4.10.2}/PKG-INFO +1 -1
  3. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_layout.py +206 -77
  4. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_er.py +60 -0
  5. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_corpus.py +7 -10
  6. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_layout.py +63 -0
  7. {termrender-4.10.0 → termrender-4.10.2}/.github/workflows/publish.yml +0 -0
  8. {termrender-4.10.0 → termrender-4.10.2}/.gitignore +0 -0
  9. {termrender-4.10.0 → termrender-4.10.2}/CLAUDE.md +0 -0
  10. {termrender-4.10.0 → termrender-4.10.2}/LICENSE +0 -0
  11. {termrender-4.10.0 → termrender-4.10.2}/README.md +0 -0
  12. {termrender-4.10.0 → termrender-4.10.2}/design.json +0 -0
  13. {termrender-4.10.0 → termrender-4.10.2}/pyproject.toml +0 -0
  14. {termrender-4.10.0 → termrender-4.10.2}/requirements.json +0 -0
  15. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/CLAUDE.md +0 -0
  16. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/__init__.py +0 -0
  17. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/__main__.py +0 -0
  18. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/blocks.py +0 -0
  19. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/emit.py +0 -0
  20. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/layout.py +0 -0
  21. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/parser.py +0 -0
  22. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/py.typed +0 -0
  23. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/CLAUDE.md +0 -0
  24. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/__init__.py +0 -0
  25. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/borders.py +0 -0
  26. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/charts.py +0 -0
  27. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/code.py +0 -0
  28. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/columns.py +0 -0
  29. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/diff.py +0 -0
  30. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/divider.py +0 -0
  31. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid.py +0 -0
  32. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_class.py +0 -0
  33. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_er.py +0 -0
  34. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow.py +0 -0
  35. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  36. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  37. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
  38. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_journey.py +0 -0
  39. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  40. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_pie.py +0 -0
  41. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
  42. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
  43. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_state.py +0 -0
  44. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_timeline.py +0 -0
  45. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/panel.py +0 -0
  46. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/quote.py +0 -0
  47. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/stat.py +0 -0
  48. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/table.py +0 -0
  49. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/text.py +0 -0
  50. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/timeline.py +0 -0
  51. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/tree.py +0 -0
  52. {termrender-4.10.0 → termrender-4.10.2}/src/termrender/style.py +0 -0
  53. {termrender-4.10.0 → termrender-4.10.2}/tests/__init__.py +0 -0
  54. {termrender-4.10.0 → termrender-4.10.2}/tests/test_charts.py +0 -0
  55. {termrender-4.10.0 → termrender-4.10.2}/tests/test_cli_contract.py +0 -0
  56. {termrender-4.10.0 → termrender-4.10.2}/tests/test_column_alignment.py +0 -0
  57. {termrender-4.10.0 → termrender-4.10.2}/tests/test_diff.py +0 -0
  58. {termrender-4.10.0 → termrender-4.10.2}/tests/test_inline_badge.py +0 -0
  59. {termrender-4.10.0 → termrender-4.10.2}/tests/test_linebreak.py +0 -0
  60. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_class.py +0 -0
  61. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_dispatch.py +0 -0
  62. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow.py +0 -0
  63. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_model.py +0 -0
  64. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_parser.py +0 -0
  65. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_shapes.py +0 -0
  66. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_gantt.py +0 -0
  67. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_journey.py +0 -0
  68. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_mindmap.py +0 -0
  69. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_pie.py +0 -0
  70. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_sequence.py +0 -0
  71. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_state.py +0 -0
  72. {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_timeline.py +0 -0
  73. {termrender-4.10.0 → termrender-4.10.2}/tests/test_myst_gaps.py +0 -0
  74. {termrender-4.10.0 → termrender-4.10.2}/tests/test_stat.py +0 -0
  75. {termrender-4.10.0 → termrender-4.10.2}/tests/test_tasklist.py +0 -0
  76. {termrender-4.10.0 → termrender-4.10.2}/tests/test_timeline.py +0 -0
  77. {termrender-4.10.0 → termrender-4.10.2}/tests/test_variable_colons.py +0 -0
  78. {termrender-4.10.0 → termrender-4.10.2}/uv.lock +0 -0
@@ -1,6 +1,38 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.10.2 (2026-07-07)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid-flow**: Split entry anchors and cjk labels
9
+ ([`0d5160d`](https://github.com/crouton-labs/termrender/commit/0d5160d18f719f3045e552236e38b640ab967b13))
10
+
11
+
12
+ ## v4.10.1 (2026-07-07)
13
+
14
+ ### Bug Fixes
15
+
16
+ - **mermaid-flow**: Constant inter-layer gap regardless of node height
17
+ ([`eb1726f`](https://github.com/crouton-labs/termrender/commit/eb1726fa4bd05a57d29026b10715edc067ac4113))
18
+
19
+ The rank-band gap override that widens spacing to fit a labeled edge scaled with visual_len(label)
20
+ (the label's *text width*) even on TB/BT diagrams, where the label is drawn horizontally across a
21
+ vertical connector and only ever needs one clear row, not one row per character. Since ER/class
22
+ diagrams route almost every edge through a label (relationship text, cardinality), this made
23
+ compartmented-node TB layouts burn ~15-17 blank connector rows per edge hop while 1-line-node
24
+ flowcharts stayed compact — node height was a red herring; the real driver was label width feeding
25
+ a row count.
26
+
27
+ _rank_gap_overrides is now direction-aware: LR/RL keeps the width-scaled gap (the transpose turns
28
+ that native row-band into the final horizontal run the label reads along, so it genuinely needs
29
+ the width). TB/BT gets a small constant, _LABELED_ROW_GAP=3, regardless of label length.
30
+
31
+ Updates the two TB labeled-edge goldens in test_mermaid_flow_corpus.py to the new compact spacing,
32
+ and adds a regression test in test_mermaid_er.py pinning a row budget for compartmented entities
33
+ so this doesn't regress.
34
+
35
+
4
36
  ## v4.10.0 (2026-07-07)
5
37
 
6
38
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.10.0
3
+ Version: 4.10.2
4
4
  Summary: Rich terminal rendering of directive-flavored markdown
5
5
  Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
6
6
  Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
@@ -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
@@ -1464,69 +1488,40 @@ def _classify_edge(
1464
1488
  return "forward" if forward else "back"
1465
1489
 
1466
1490
 
1467
- def _allocate_edge_anchors(
1491
+ def _spread_group_anchors(
1468
1492
  rects: dict[str, BoxRect],
1469
1493
  direction: Direction,
1470
1494
  edges: list[FlowEdge],
1471
- nodes: list[FlowNode] | None = None,
1495
+ node_by_id: dict[str, "FlowNode"],
1496
+ groups: dict[tuple[str, str], list[int]],
1497
+ is_marked,
1498
+ other_end,
1472
1499
  ) -> 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.
1500
+ """Shared spreading logic behind :func:`_allocate_edge_anchors`'s two
1501
+ passes (source exits, destination entries): for each ``(node_id,
1502
+ side)`` group of 2+ edges that share a border side at ``node_id``,
1503
+ spread their anchor points along that side's usable span (see
1504
+ :func:`_side_interior_span`) — but only when 2+ of the group's edges
1505
+ are ``is_marked`` (carry an arrow marker on the end at ``node_id``).
1506
+ ``other_end(edge)`` gives the node id at the *other* end of an edge,
1507
+ used only to order the spread left-to-right/top-to-bottom by where
1508
+ each edge's far endpoint sits off-axis, so the spread reads in the
1509
+ same visual order the paths already fan out in. Returns a dict keyed
1510
+ by index into ``edges``; groups left ungrouped (size < 2) or
1511
+ under-marked (< 2 marked) are entirely absent from it.
1501
1512
  """
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
1513
  overrides: dict[int, tuple[int, int]] = {}
1515
1514
  for (node_id, side), idxs in groups.items():
1516
1515
  if len(idxs) < 2:
1517
1516
  continue
1518
- marked = [
1519
- i
1520
- for i in idxs
1521
- if edges[i].src_arrow or edges[i].src_arrow_kind != "default"
1522
- ]
1517
+ marked = [i for i in idxs if is_marked(edges[i])]
1523
1518
  if len(marked) < 2:
1524
1519
  continue
1525
1520
  rect = rects.get(node_id)
1526
1521
  if rect is None:
1527
1522
  continue
1528
1523
  ordered = sorted(
1529
- idxs, key=lambda i: _abstract(direction, rects[edges[i].dst].center)[1]
1524
+ idxs, key=lambda i: _abstract(direction, rects[other_end(edges[i])].center)[1]
1530
1525
  )
1531
1526
  lo, hi = _side_span_for_node(node_by_id.get(node_id), rect, side)
1532
1527
  spread = _spread_points(lo, hi, len(ordered))
@@ -1535,6 +1530,94 @@ def _allocate_edge_anchors(
1535
1530
  return overrides
1536
1531
 
1537
1532
 
1533
+ def _allocate_edge_anchors(
1534
+ rects: dict[str, BoxRect],
1535
+ direction: Direction,
1536
+ edges: list[FlowEdge],
1537
+ nodes: list[FlowNode] | None = None,
1538
+ ) -> tuple[dict[int, tuple[int, int]], dict[int, tuple[int, int]]]:
1539
+ """Pre-pass over every edge: group forward/back edges by the border
1540
+ side they share with sibling edges at the *same* node, on **both**
1541
+ ends independently — exit side at the source (``(src, exit side)`` for
1542
+ forward edges, ``(src, lane side)`` for back-edges) and entry side at
1543
+ the destination (``(dst, entry side)`` for forward edges, ``(dst, lane
1544
+ side)`` for back-edges, since a back-edge's destination anchor is also
1545
+ :func:`_lane_anchor`, the same physical side as its source anchor).
1546
+ Within each group, spread the anchor points along that side's usable
1547
+ span (see :func:`_side_interior_span`) — but **only** when 2+ of the
1548
+ group's edges carry an arrow marker on the end at that shared node.
1549
+ The two ends use different tests, deliberately not symmetric in *how*
1550
+ they detect a marker even though the spreading they trigger is: an
1551
+ exit group tests ``src_arrow or src_arrow_kind != "default"`` (a
1552
+ source-side arrowhead of any kind is already rare — most edges have
1553
+ none — so its mere presence is already a meaningful signal), while an
1554
+ entry group tests only ``dst_arrow_kind != "default"`` (a
1555
+ destination-side arrowhead is the *default* for an ordinary ``-->``
1556
+ edge — ``dst_arrow`` is ``True`` almost everywhere — so testing its
1557
+ bare presence would spread every ordinary fan-in; only a non-default
1558
+ *kind*, the UML composition/aggregation diamond case documented in
1559
+ ``mermaid_class.py``, is the actual collision this closes). Left
1560
+ stacked on one shared anchor otherwise (the overwhelming majority of
1561
+ groups): a plain, markerless
1562
+ multi-edge fan-out (``A-->B; A-->C``) or fan-in (``A-->C; B-->C``)
1563
+ *relies* on sharing one exit/entry cell for its trunk-then-tee look
1564
+ (draw_segment's junction bitmask resolves the shared point into a
1565
+ single ┬/┴, not two disjoint stubs) — spreading it would only change
1566
+ this module's own aesthetic choice of where the fan visually splits,
1567
+ not fix anything, and would break that look for every existing fan-
1568
+ out/merge golden. A marker glyph, unlike a plain line junction, has no
1569
+ such union — two different marker glyphs landing on one cell just
1570
+ silently lose all but the last-drawn one, which is the actual defect
1571
+ this closes, on whichever end (source or destination) carries the
1572
+ colliding markers. (The sibling label-collision defect — two edges
1573
+ from a shared exit tying for "longest straight run" so a second label
1574
+ lands on cells the first already claimed — is fixed separately, in
1575
+ :func:`_longest_segment`'s tie-break, since it needs no anchor change
1576
+ at all.) Returns a ``(exit_overrides, entry_overrides)`` pair, each a
1577
+ dict keyed by index into ``edges``; an edge absent from one keeps the
1578
+ engine's single fixed anchor on that end (:func:`_forward_exit`/
1579
+ :func:`_forward_entry`/:func:`_lane_anchor`) unchanged. Same-rank
1580
+ edges and self-loops are never grouped here — same-rank anchors are
1581
+ already per-pair (:func:`_facing_anchor`, dynamic per destination, not
1582
+ a fixed shared side), and self-loops already stack via their own
1583
+ ``self_loop_counter``-driven reach.
1584
+ """
1585
+ node_by_id = {n.id: n for n in (nodes or [])}
1586
+ exit_groups: dict[tuple[str, str], list[int]] = defaultdict(list)
1587
+ entry_groups: dict[tuple[str, str], list[int]] = defaultdict(list)
1588
+ for i, e in enumerate(edges):
1589
+ if e.src == e.dst:
1590
+ continue
1591
+ kind = _classify_edge(rects, direction, e)
1592
+ if kind == "forward":
1593
+ exit_groups[(e.src, _forward_exit_side(direction))].append(i)
1594
+ entry_groups[(e.dst, _forward_entry_side(direction))].append(i)
1595
+ elif kind == "back":
1596
+ exit_groups[(e.src, _lane_side(direction))].append(i)
1597
+ entry_groups[(e.dst, _lane_side(direction))].append(i)
1598
+ # "same-rank" / None (dangling): no override, unchanged behavior.
1599
+
1600
+ exit_overrides = _spread_group_anchors(
1601
+ rects,
1602
+ direction,
1603
+ edges,
1604
+ node_by_id,
1605
+ exit_groups,
1606
+ lambda e: e.src_arrow or e.src_arrow_kind != "default",
1607
+ lambda e: e.dst,
1608
+ )
1609
+ entry_overrides = _spread_group_anchors(
1610
+ rects,
1611
+ direction,
1612
+ edges,
1613
+ node_by_id,
1614
+ entry_groups,
1615
+ lambda e: e.dst_arrow_kind != "default",
1616
+ lambda e: e.src,
1617
+ )
1618
+ return exit_overrides, entry_overrides
1619
+
1620
+
1538
1621
  def _abstract(direction: Direction, point: tuple[int, int]) -> tuple[int, int]:
1539
1622
  """``point`` decomposed into (primary, secondary) = (rank-axis coord,
1540
1623
  off-axis coord) — the coordinate frame the path-shape helpers reason
@@ -1657,6 +1740,53 @@ def _reserve_label_margin(canvas: Canvas, x: int, y: int) -> None:
1657
1740
  canvas.set_char(x, y, canvas.get_char(x, y), reserve=True)
1658
1741
 
1659
1742
 
1743
+ def _label_cell_widths(label: str) -> list[int]:
1744
+ """Per-character visual width of ``label``, as successive
1745
+ :func:`~termrender.style.visual_len` prefix deltas — a wide
1746
+ CJK/fullwidth character reports ``2`` here without this module
1747
+ needing to duplicate ``visual_len``'s own east-asian-width table (the
1748
+ deltas telescope to ``visual_len(label)`` by construction, so this
1749
+ always agrees with the total cell count :func:`_draw_label_on_segment`
1750
+ already reserves for the label)."""
1751
+ widths = []
1752
+ prev = 0
1753
+ for i in range(1, len(label) + 1):
1754
+ cur = visual_len(label[: i])
1755
+ widths.append(cur - prev)
1756
+ prev = cur
1757
+ return widths
1758
+
1759
+
1760
+ def _write_label_run(
1761
+ canvas: Canvas, start: int, row: int, label: str, check_reserved: bool
1762
+ ) -> None:
1763
+ """Write ``label`` into row ``row`` starting at column ``start``,
1764
+ advancing the write cursor by each character's own visual width (see
1765
+ :func:`_label_cell_widths`) instead of one grid cell per Python code
1766
+ point. A single-width character still occupies just its one cell; a
1767
+ wide CJK/fullwidth character's glyph lands in its first cell only,
1768
+ and the *next* cell it visually covers is marked reserved and left
1769
+ blank — without this, that second cell stays unreserved and a
1770
+ connector glyph drawn earlier (edges draw before labels, see
1771
+ :func:`layout_flowgraph`) remains visible inside the label's own
1772
+ visual width. ``check_reserved`` mirrors the two call sites' existing
1773
+ behavior: the "clear run found" placement writes unconditionally (the
1774
+ whole run was already verified clear by the caller), the
1775
+ overflow-tolerated fallback placement instead skips any individual
1776
+ cell that's already reserved, one cell at a time (never overwriting a
1777
+ box).
1778
+ """
1779
+ x = start
1780
+ for ch, w in zip(label, _label_cell_widths(label)):
1781
+ if not check_reserved or not canvas.is_reserved(x, row):
1782
+ canvas.set_char(x, row, ch, reserve=True)
1783
+ for extra in range(1, w):
1784
+ cx = x + extra
1785
+ if not check_reserved or not canvas.is_reserved(cx, row):
1786
+ canvas.set_char(cx, row, "", reserve=True)
1787
+ x += w
1788
+
1789
+
1660
1790
  def _draw_label_on_segment(
1661
1791
  canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
1662
1792
  ) -> None:
@@ -1693,15 +1823,12 @@ def _draw_label_on_segment(
1693
1823
  for start in _label_positions(length, center, lo, hi):
1694
1824
  run = cells(start)
1695
1825
  if not any(canvas.is_reserved(x, y) for x, y in run):
1696
- for (px, py), ch in zip(run, label):
1697
- canvas.set_char(px, py, ch, reserve=True)
1826
+ _write_label_run(canvas, start, row, label, check_reserved=False)
1698
1827
  _reserve_label_margin(canvas, start - 1, row)
1699
1828
  _reserve_label_margin(canvas, start + length, row)
1700
1829
  return
1701
1830
  start = max(lo, min(center - length // 2, hi - length + 1))
1702
- for (px, py), ch in zip(cells(start), label):
1703
- if not canvas.is_reserved(px, py):
1704
- canvas.set_char(px, py, ch, reserve=True)
1831
+ _write_label_run(canvas, start, row, label, check_reserved=True)
1705
1832
  return
1706
1833
 
1707
1834
  # Vertical segment: the label still reads horizontally, centered on
@@ -1721,14 +1848,11 @@ def _draw_label_on_segment(
1721
1848
  for row in _label_positions(1, center_row, lo, hi):
1722
1849
  run = row_cells(row)
1723
1850
  if not any(canvas.is_reserved(x, y) for x, y in run):
1724
- for (px, py), ch in zip(run, label):
1725
- canvas.set_char(px, py, ch, reserve=True)
1851
+ _write_label_run(canvas, start_x, row, label, check_reserved=False)
1726
1852
  _reserve_label_margin(canvas, start_x - 1, row)
1727
1853
  _reserve_label_margin(canvas, start_x + length, row)
1728
1854
  return
1729
- for (px, py), ch in zip(row_cells(center_row), label):
1730
- if not canvas.is_reserved(px, py):
1731
- canvas.set_char(px, py, ch, reserve=True)
1855
+ _write_label_run(canvas, start_x, center_row, label, check_reserved=True)
1732
1856
 
1733
1857
 
1734
1858
  def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
@@ -1785,6 +1909,7 @@ def _route_edge_path(
1785
1909
  lane_counter: list[int],
1786
1910
  self_loop_counter: dict[str, int],
1787
1911
  exit_anchor: tuple[int, int] | None = None,
1912
+ entry_anchor: tuple[int, int] | None = None,
1788
1913
  ) -> list[tuple[int, int]] | None:
1789
1914
  """Compute one edge's polyline points: anchors chosen by relative rank
1790
1915
  position, an L/Z/C path shape. Pure path computation only — drawing is
@@ -1794,13 +1919,14 @@ def _route_edge_path(
1794
1919
  return ``None`` (defensive no-op — the parser guarantees valid
1795
1920
  endpoints; this module never crashes on one).
1796
1921
 
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
1922
+ ``exit_anchor``/``entry_anchor``, when given (from
1923
+ :func:`_allocate_edge_anchors`), replace the src/dst border point that
1924
+ would otherwise come from :func:`_forward_exit`/:func:`_forward_entry`/
1925
+ :func:`_lane_anchor` used only when this edge's exit/entry side is
1926
+ shared with a sibling edge that also carries an arrow marker on that
1927
+ same end; ``None`` (the overwhelming common case, for either) reproduces
1928
+ the original single-anchor behavior exactly on that end. Same-rank
1929
+ edges and self-loops ignore both (see :func:`_allocate_edge_anchors`'s
1804
1930
  docstring for why).
1805
1931
  """
1806
1932
  src_rect = rects.get(edge.src)
@@ -1820,7 +1946,8 @@ def _route_edge_path(
1820
1946
 
1821
1947
  if kind == "forward":
1822
1948
  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))
1949
+ entry_pt = entry_anchor if entry_anchor is not None else _forward_entry(direction, dst_rect)
1950
+ return _z_path(direction, exit_pt, entry_pt)
1824
1951
 
1825
1952
  lane = lane_counter[0]
1826
1953
  lane_counter[0] += 1
@@ -1830,9 +1957,8 @@ def _route_edge_path(
1830
1957
  + lane * _LANE_GAP
1831
1958
  )
1832
1959
  exit_pt = exit_anchor if exit_anchor is not None else _lane_anchor(direction, src_rect)
1833
- return _lane_path(
1834
- direction, exit_pt, _lane_anchor(direction, dst_rect), lane_secondary
1835
- )
1960
+ entry_pt = entry_anchor if entry_anchor is not None else _lane_anchor(direction, dst_rect)
1961
+ return _lane_path(direction, exit_pt, entry_pt, lane_secondary)
1836
1962
 
1837
1963
 
1838
1964
  # --------------------------------------------------------------------------
@@ -1909,7 +2035,9 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
1909
2035
  # a cell — labels are only ever safe once no more lines are coming.
1910
2036
  lane_counter = [0]
1911
2037
  self_loop_counter: dict[str, int] = {}
1912
- exit_overrides = _allocate_edge_anchors(rects, g.direction, g.edges, g.nodes)
2038
+ exit_overrides, entry_overrides = _allocate_edge_anchors(
2039
+ rects, g.direction, g.edges, g.nodes
2040
+ )
1913
2041
  edge_paths: list[tuple[FlowEdge, list[tuple[int, int]]]] = []
1914
2042
  for i, e in enumerate(g.edges):
1915
2043
  points = _route_edge_path(
@@ -1919,6 +2047,7 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
1919
2047
  lane_counter,
1920
2048
  self_loop_counter,
1921
2049
  exit_overrides.get(i),
2050
+ entry_overrides.get(i),
1922
2051
  )
1923
2052
  if points is not None:
1924
2053
  edge_paths.append((e, points))
@@ -265,6 +265,66 @@ def test_order_example_golden_topology():
265
265
  assert cust_row < order_row < line_item_row
266
266
 
267
267
 
268
+ # --------------------------------------------------------------------------
269
+ # Vertical compactness (regression: constant inter-layer gap regardless of
270
+ # node height — see mermaid_flow_layout._LABELED_ROW_GAP)
271
+ # --------------------------------------------------------------------------
272
+
273
+
274
+ def test_compartmented_entities_get_constant_gap_not_height_scaled():
275
+ # Two tall (compartmented) entities joined by one labeled relationship
276
+ # used to burn ~15-17 blank connector rows for this single edge hop —
277
+ # the inter-rank gap scaled with the edge label's *text length*
278
+ # (visual_len), not with node height, but on ER/class diagrams nearly
279
+ # every edge carries a label, so the effect showed up exactly when
280
+ # nodes were tall. The gap between adjacent-rank boxes must now stay a
281
+ # small constant: enough for a connector row, the label's own row, and
282
+ # one more connector row — independent of both the label's length and
283
+ # the boxes' height.
284
+ src = (
285
+ "erDiagram\n"
286
+ " CUSTOMER ||--o{ ORDER : places\n"
287
+ " CUSTOMER {\n"
288
+ " string name PK\n"
289
+ " string email\n"
290
+ " }\n"
291
+ " ORDER {\n"
292
+ " int orderNumber PK\n"
293
+ " string status\n"
294
+ " }\n"
295
+ )
296
+ lines = _lines(src, width=100)
297
+ assert _has_box_glyphs(lines)
298
+ joined = "\n".join(lines)
299
+
300
+ # Full row budget: two 6-row compartmented boxes plus a small constant
301
+ # gap between them — well under the ~27 rows the scaling bug produced.
302
+ assert len(lines) <= 18, f"expected a compact render, got {len(lines)} rows:\n{joined}"
303
+
304
+ # The label survives in full, uncorrupted, and every box border row is
305
+ # single-glyph clean (no overlapping glyphs from a collapsed gap).
306
+ assert "places" in joined
307
+ for attr in ("string name PK", "string email", "int orderNumber PK", "string status"):
308
+ assert attr in joined
309
+
310
+ cust_row, _ = _row_col(lines, "CUSTOMER")
311
+ order_row, _ = _row_col(lines, "ORDER")
312
+ label_row, _ = _row_col(lines, "places")
313
+ assert cust_row < label_row < order_row
314
+
315
+ # The connector gap between the two boxes is a small constant, not
316
+ # scaled by the label's width: the blank/connector run directly above
317
+ # and below the label row is short.
318
+ cust_bottom = next(
319
+ r for r in range(cust_row, len(lines)) if "└" in lines[r]
320
+ )
321
+ order_top = next(
322
+ r for r in range(order_row, -1, -1) if "┌" in lines[r]
323
+ )
324
+ gap_rows = order_top - cust_bottom - 1
325
+ assert gap_rows <= 5, f"inter-layer gap grew to {gap_rows} rows:\n{joined}"
326
+
327
+
268
328
  # --------------------------------------------------------------------------
269
329
  # Degradation: malformed input, empty body, literal-glyph sanitization
270
330
  # --------------------------------------------------------------------------
@@ -195,9 +195,10 @@ def test_edge_style_bidirectional_golden():
195
195
 
196
196
 
197
197
  def test_pipe_label_golden():
198
- # The rank-band gap widens to fit the label in full (see
199
- # mermaid_flow_layout._rank_gap_overrides) a 2-char label needs a
200
- # taller gap than the base _ROW_GAP alone provides.
198
+ # The rank-band gap widens past the unlabeled _ROW_GAP to a small
199
+ # constant (see mermaid_flow_layout._LABELED_ROW_GAP) so the label gets
200
+ # its own clear row — the constant does not scale with the label's
201
+ # text length.
201
202
  assert _lines("graph TD\nA-->|hi|B\n") == [
202
203
  "┌───┐",
203
204
  "│ A │",
@@ -205,7 +206,6 @@ def test_pipe_label_golden():
205
206
  " │",
206
207
  " hi",
207
208
  " │",
208
- " │",
209
209
  "┌─▼─┐",
210
210
  "│ B │",
211
211
  "└───┘",
@@ -213,19 +213,16 @@ def test_pipe_label_golden():
213
213
 
214
214
 
215
215
  def test_inline_label_golden():
216
- # Same rank-band widening as above, scaled to the longer "go now" label.
216
+ # Same constant rank-band gap as above "go now" is longer than "hi"
217
+ # but the vertical gap stays the same _LABELED_ROW_GAP; only the
218
+ # label's own row is wider.
217
219
  assert _lines("graph TD\nA -- go now --> B\n") == [
218
220
  "┌───┐",
219
221
  "│ A │",
220
222
  "└───┘",
221
223
  " │",
222
- " │",
223
- " │",
224
224
  "go now",
225
225
  " │",
226
- " │",
227
- " │",
228
- " │",
229
226
  "┌─▼─┐",
230
227
  "│ B │",
231
228
  "└───┘",
@@ -474,3 +474,66 @@ def test_duplicate_edges_and_empty_labels_render_without_raising():
474
474
  assert lines, "a well-typed non-empty graph must still render"
475
475
  text = "\n".join(lines)
476
476
  assert text.count("\u250c") == 2, "expected exactly the two declared node boxes"
477
+
478
+
479
+ # --------------------------------------------------------------------------
480
+ # Destination-side marker anchors (regression: _allocate_edge_anchors used to
481
+ # spread shared *source* exits only, so 2+ edges entering one node with
482
+ # different destination-end markers all landed on the single fixed
483
+ # _forward_entry anchor and silently lost all but the last-drawn glyph)
484
+ # --------------------------------------------------------------------------
485
+
486
+
487
+ def test_two_destination_side_markers_into_one_node_both_survive():
488
+ g = FlowGraph(
489
+ direction=Direction.TB,
490
+ nodes=[_node("A"), _node("B"), _node("C")],
491
+ edges=[
492
+ _edge("A", "C", dst_arrow_kind="diamond_filled"),
493
+ _edge("B", "C", dst_arrow_kind="diamond_hollow"),
494
+ ],
495
+ )
496
+ lines = layout_flowgraph(g, width=80)
497
+ text = "\n".join(lines)
498
+ assert "\u25c6" in text, "diamond_filled destination marker must survive"
499
+ assert "\u25c7" in text, "diamond_hollow destination marker must survive"
500
+
501
+
502
+ def test_plain_arrow_fan_in_still_shares_one_entry_anchor():
503
+ # Guards the deliberate scoping this fix must not break: an ordinary
504
+ # fan-in (no non-default dst_arrow_kind — an everyday "-->" edge
505
+ # already sets dst_arrow=True, so the trigger must be the *kind*, not
506
+ # bare arrow presence) still lands on one shared entry cell so
507
+ # draw_segment's junction bitmask resolves it into a single ┬/┴ trunk
508
+ # rather than two disjoint stubs either side of the node.
509
+ g = FlowGraph(
510
+ direction=Direction.TB,
511
+ nodes=[_node("A"), _node("B"), _node("C")],
512
+ edges=[_edge("A", "C"), _edge("B", "C")],
513
+ )
514
+ lines = layout_flowgraph(g, width=80)
515
+ text = "\n".join(lines)
516
+ assert "\u252c" in text or "\u2534" in text, "plain fan-in should keep its shared trunk look"
517
+
518
+
519
+ # --------------------------------------------------------------------------
520
+ # CJK edge labels (regression: label placement measured visual_len(label)
521
+ # for reservation/search but wrote/reserved one grid cell per Python code
522
+ # point, so a wide CJK label under-reserved and a connector glyph already
523
+ # drawn on the segment stayed visible inside the label's own visual width)
524
+ # --------------------------------------------------------------------------
525
+
526
+
527
+ def test_cjk_edge_label_has_no_connector_bleed_inside_its_width():
528
+ g = FlowGraph(
529
+ direction=Direction.TB,
530
+ nodes=[_node("A"), _node("B")],
531
+ edges=[_edge("A", "B", label="\u4f60\u597d")], # "你好"
532
+ )
533
+ lines = layout_flowgraph(g, width=80)
534
+ label_row = next(line for line in lines if "\u4f60\u597d" in line)
535
+ assert not _BOX_GLYPH_RE.search(label_row), (
536
+ "a connector/box glyph landed inside the CJK label's own visual width: "
537
+ f"{label_row!r}"
538
+ )
539
+ assert label_row.strip() == "\u4f60\u597d"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes