termrender 4.10.1__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.1 → termrender-4.10.2}/CHANGELOG.md +8 -0
  2. {termrender-4.10.1 → termrender-4.10.2}/PKG-INFO +1 -1
  3. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_layout.py +175 -70
  4. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_layout.py +63 -0
  5. {termrender-4.10.1 → termrender-4.10.2}/.github/workflows/publish.yml +0 -0
  6. {termrender-4.10.1 → termrender-4.10.2}/.gitignore +0 -0
  7. {termrender-4.10.1 → termrender-4.10.2}/CLAUDE.md +0 -0
  8. {termrender-4.10.1 → termrender-4.10.2}/LICENSE +0 -0
  9. {termrender-4.10.1 → termrender-4.10.2}/README.md +0 -0
  10. {termrender-4.10.1 → termrender-4.10.2}/design.json +0 -0
  11. {termrender-4.10.1 → termrender-4.10.2}/pyproject.toml +0 -0
  12. {termrender-4.10.1 → termrender-4.10.2}/requirements.json +0 -0
  13. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/CLAUDE.md +0 -0
  14. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/__init__.py +0 -0
  15. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/__main__.py +0 -0
  16. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/blocks.py +0 -0
  17. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/emit.py +0 -0
  18. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/layout.py +0 -0
  19. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/parser.py +0 -0
  20. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/py.typed +0 -0
  21. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/CLAUDE.md +0 -0
  22. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/__init__.py +0 -0
  23. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/borders.py +0 -0
  24. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/charts.py +0 -0
  25. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/code.py +0 -0
  26. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/columns.py +0 -0
  27. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/diff.py +0 -0
  28. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/divider.py +0 -0
  29. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid.py +0 -0
  30. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_class.py +0 -0
  31. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_er.py +0 -0
  32. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow.py +0 -0
  33. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  34. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  35. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
  36. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_journey.py +0 -0
  37. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  38. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_pie.py +0 -0
  39. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
  40. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
  41. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_state.py +0 -0
  42. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_timeline.py +0 -0
  43. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/panel.py +0 -0
  44. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/quote.py +0 -0
  45. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/stat.py +0 -0
  46. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/table.py +0 -0
  47. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/text.py +0 -0
  48. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/timeline.py +0 -0
  49. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/tree.py +0 -0
  50. {termrender-4.10.1 → termrender-4.10.2}/src/termrender/style.py +0 -0
  51. {termrender-4.10.1 → termrender-4.10.2}/tests/__init__.py +0 -0
  52. {termrender-4.10.1 → termrender-4.10.2}/tests/test_charts.py +0 -0
  53. {termrender-4.10.1 → termrender-4.10.2}/tests/test_cli_contract.py +0 -0
  54. {termrender-4.10.1 → termrender-4.10.2}/tests/test_column_alignment.py +0 -0
  55. {termrender-4.10.1 → termrender-4.10.2}/tests/test_diff.py +0 -0
  56. {termrender-4.10.1 → termrender-4.10.2}/tests/test_inline_badge.py +0 -0
  57. {termrender-4.10.1 → termrender-4.10.2}/tests/test_linebreak.py +0 -0
  58. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_class.py +0 -0
  59. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_dispatch.py +0 -0
  60. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_er.py +0 -0
  61. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow.py +0 -0
  62. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_corpus.py +0 -0
  63. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_model.py +0 -0
  64. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_parser.py +0 -0
  65. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_shapes.py +0 -0
  66. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_gantt.py +0 -0
  67. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_journey.py +0 -0
  68. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_mindmap.py +0 -0
  69. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_pie.py +0 -0
  70. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_sequence.py +0 -0
  71. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_state.py +0 -0
  72. {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_timeline.py +0 -0
  73. {termrender-4.10.1 → termrender-4.10.2}/tests/test_myst_gaps.py +0 -0
  74. {termrender-4.10.1 → termrender-4.10.2}/tests/test_stat.py +0 -0
  75. {termrender-4.10.1 → termrender-4.10.2}/tests/test_tasklist.py +0 -0
  76. {termrender-4.10.1 → termrender-4.10.2}/tests/test_timeline.py +0 -0
  77. {termrender-4.10.1 → termrender-4.10.2}/tests/test_variable_colons.py +0 -0
  78. {termrender-4.10.1 → termrender-4.10.2}/uv.lock +0 -0
@@ -1,6 +1,14 @@
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
+
4
12
  ## v4.10.1 (2026-07-07)
5
13
 
6
14
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.10.1
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
@@ -1488,69 +1488,40 @@ def _classify_edge(
1488
1488
  return "forward" if forward else "back"
1489
1489
 
1490
1490
 
1491
- def _allocate_edge_anchors(
1491
+ def _spread_group_anchors(
1492
1492
  rects: dict[str, BoxRect],
1493
1493
  direction: Direction,
1494
1494
  edges: list[FlowEdge],
1495
- 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,
1496
1499
  ) -> dict[int, tuple[int, int]]:
1497
- """Pre-pass over every edge: group forward/back edges by the exit side
1498
- they share with sibling edges leaving the *same* node (``(src, exit
1499
- side)`` for forward edges, ``(src, lane side)`` for back-edges), and
1500
- spread a group's anchor points along that side's usable span (see
1501
- :func:`_side_interior_span`) — but **only** when 2+ of that group's
1502
- edges carry a source-side arrow marker (``src_arrow`` or a non-default
1503
- ``src_arrow_kind`` the UML composition/aggregation case documented
1504
- in ``mermaid_class.py``). Left stacked on one shared anchor otherwise
1505
- (the overwhelming majority of groups): a plain, markerless multi-edge
1506
- fan-out (``A-->B; A-->C``) *relies* on sharing one exit cell for its
1507
- trunk-then-tee look (draw_segment's junction bitmask resolves the
1508
- shared point into a single ┬/┴, not two disjoint stubs) — spreading it
1509
- would only change this module's own aesthetic choice of where the fan
1510
- visually splits, not fix anything, and would break that look for every
1511
- existing fan-out/merge golden. A marker glyph, unlike a plain line
1512
- junction, has no such union — two different marker glyphs landing on
1513
- one cell just silently lose all but the last-drawn one, which is the
1514
- actual defect this closes. (The sibling label-collision defect — two
1515
- edges from a shared exit tying for "longest straight run" so a second
1516
- label lands on cells the first already claimed — is fixed separately,
1517
- in :func:`_longest_segment`'s tie-break, since it needs no anchor
1518
- change at all.) Returns a dict keyed by index into ``edges``; an edge
1519
- absent from it keeps the engine's single fixed exit anchor
1520
- (:func:`_forward_exit`/:func:`_lane_anchor`) unchanged. Same-rank
1521
- edges and self-loops are never grouped here — same-rank anchors are
1522
- already per-pair (:func:`_facing_anchor`, dynamic per destination, not
1523
- a fixed shared side), and self-loops already stack via their own
1524
- ``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.
1525
1512
  """
1526
- node_by_id = {n.id: n for n in (nodes or [])}
1527
- groups: dict[tuple[str, str], list[int]] = defaultdict(list)
1528
- for i, e in enumerate(edges):
1529
- if e.src == e.dst:
1530
- continue
1531
- kind = _classify_edge(rects, direction, e)
1532
- if kind == "forward":
1533
- groups[(e.src, _forward_exit_side(direction))].append(i)
1534
- elif kind == "back":
1535
- groups[(e.src, _lane_side(direction))].append(i)
1536
- # "same-rank" / None (dangling): no override, unchanged behavior.
1537
-
1538
1513
  overrides: dict[int, tuple[int, int]] = {}
1539
1514
  for (node_id, side), idxs in groups.items():
1540
1515
  if len(idxs) < 2:
1541
1516
  continue
1542
- marked = [
1543
- i
1544
- for i in idxs
1545
- if edges[i].src_arrow or edges[i].src_arrow_kind != "default"
1546
- ]
1517
+ marked = [i for i in idxs if is_marked(edges[i])]
1547
1518
  if len(marked) < 2:
1548
1519
  continue
1549
1520
  rect = rects.get(node_id)
1550
1521
  if rect is None:
1551
1522
  continue
1552
1523
  ordered = sorted(
1553
- 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]
1554
1525
  )
1555
1526
  lo, hi = _side_span_for_node(node_by_id.get(node_id), rect, side)
1556
1527
  spread = _spread_points(lo, hi, len(ordered))
@@ -1559,6 +1530,94 @@ def _allocate_edge_anchors(
1559
1530
  return overrides
1560
1531
 
1561
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
+
1562
1621
  def _abstract(direction: Direction, point: tuple[int, int]) -> tuple[int, int]:
1563
1622
  """``point`` decomposed into (primary, secondary) = (rank-axis coord,
1564
1623
  off-axis coord) — the coordinate frame the path-shape helpers reason
@@ -1681,6 +1740,53 @@ def _reserve_label_margin(canvas: Canvas, x: int, y: int) -> None:
1681
1740
  canvas.set_char(x, y, canvas.get_char(x, y), reserve=True)
1682
1741
 
1683
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
+
1684
1790
  def _draw_label_on_segment(
1685
1791
  canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
1686
1792
  ) -> None:
@@ -1717,15 +1823,12 @@ def _draw_label_on_segment(
1717
1823
  for start in _label_positions(length, center, lo, hi):
1718
1824
  run = cells(start)
1719
1825
  if not any(canvas.is_reserved(x, y) for x, y in run):
1720
- for (px, py), ch in zip(run, label):
1721
- canvas.set_char(px, py, ch, reserve=True)
1826
+ _write_label_run(canvas, start, row, label, check_reserved=False)
1722
1827
  _reserve_label_margin(canvas, start - 1, row)
1723
1828
  _reserve_label_margin(canvas, start + length, row)
1724
1829
  return
1725
1830
  start = max(lo, min(center - length // 2, hi - length + 1))
1726
- for (px, py), ch in zip(cells(start), label):
1727
- if not canvas.is_reserved(px, py):
1728
- canvas.set_char(px, py, ch, reserve=True)
1831
+ _write_label_run(canvas, start, row, label, check_reserved=True)
1729
1832
  return
1730
1833
 
1731
1834
  # Vertical segment: the label still reads horizontally, centered on
@@ -1745,14 +1848,11 @@ def _draw_label_on_segment(
1745
1848
  for row in _label_positions(1, center_row, lo, hi):
1746
1849
  run = row_cells(row)
1747
1850
  if not any(canvas.is_reserved(x, y) for x, y in run):
1748
- for (px, py), ch in zip(run, label):
1749
- canvas.set_char(px, py, ch, reserve=True)
1851
+ _write_label_run(canvas, start_x, row, label, check_reserved=False)
1750
1852
  _reserve_label_margin(canvas, start_x - 1, row)
1751
1853
  _reserve_label_margin(canvas, start_x + length, row)
1752
1854
  return
1753
- for (px, py), ch in zip(row_cells(center_row), label):
1754
- if not canvas.is_reserved(px, py):
1755
- canvas.set_char(px, py, ch, reserve=True)
1855
+ _write_label_run(canvas, start_x, center_row, label, check_reserved=True)
1756
1856
 
1757
1857
 
1758
1858
  def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
@@ -1809,6 +1909,7 @@ def _route_edge_path(
1809
1909
  lane_counter: list[int],
1810
1910
  self_loop_counter: dict[str, int],
1811
1911
  exit_anchor: tuple[int, int] | None = None,
1912
+ entry_anchor: tuple[int, int] | None = None,
1812
1913
  ) -> list[tuple[int, int]] | None:
1813
1914
  """Compute one edge's polyline points: anchors chosen by relative rank
1814
1915
  position, an L/Z/C path shape. Pure path computation only — drawing is
@@ -1818,13 +1919,14 @@ def _route_edge_path(
1818
1919
  return ``None`` (defensive no-op — the parser guarantees valid
1819
1920
  endpoints; this module never crashes on one).
1820
1921
 
1821
- ``exit_anchor``, when given (from :func:`_allocate_edge_anchors`),
1822
- replaces the src border point that would otherwise come from
1823
- :func:`_forward_exit`/:func:`_lane_anchor` — used only when this
1824
- edge's exit side is shared with a sibling edge that also carries a
1825
- source-side arrow marker; ``None`` (the overwhelming common case)
1826
- reproduces the original single-anchor behavior exactly. Same-rank
1827
- 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
1828
1930
  docstring for why).
1829
1931
  """
1830
1932
  src_rect = rects.get(edge.src)
@@ -1844,7 +1946,8 @@ def _route_edge_path(
1844
1946
 
1845
1947
  if kind == "forward":
1846
1948
  exit_pt = exit_anchor if exit_anchor is not None else _forward_exit(direction, src_rect)
1847
- 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)
1848
1951
 
1849
1952
  lane = lane_counter[0]
1850
1953
  lane_counter[0] += 1
@@ -1854,9 +1957,8 @@ def _route_edge_path(
1854
1957
  + lane * _LANE_GAP
1855
1958
  )
1856
1959
  exit_pt = exit_anchor if exit_anchor is not None else _lane_anchor(direction, src_rect)
1857
- return _lane_path(
1858
- direction, exit_pt, _lane_anchor(direction, dst_rect), lane_secondary
1859
- )
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)
1860
1962
 
1861
1963
 
1862
1964
  # --------------------------------------------------------------------------
@@ -1933,7 +2035,9 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
1933
2035
  # a cell — labels are only ever safe once no more lines are coming.
1934
2036
  lane_counter = [0]
1935
2037
  self_loop_counter: dict[str, int] = {}
1936
- 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
+ )
1937
2041
  edge_paths: list[tuple[FlowEdge, list[tuple[int, int]]]] = []
1938
2042
  for i, e in enumerate(g.edges):
1939
2043
  points = _route_edge_path(
@@ -1943,6 +2047,7 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
1943
2047
  lane_counter,
1944
2048
  self_loop_counter,
1945
2049
  exit_overrides.get(i),
2050
+ entry_overrides.get(i),
1946
2051
  )
1947
2052
  if points is not None:
1948
2053
  edge_paths.append((e, points))
@@ -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