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.
- {termrender-4.10.0 → termrender-4.10.2}/CHANGELOG.md +32 -0
- {termrender-4.10.0 → termrender-4.10.2}/PKG-INFO +1 -1
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_layout.py +206 -77
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_er.py +60 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_corpus.py +7 -10
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_layout.py +63 -0
- {termrender-4.10.0 → termrender-4.10.2}/.github/workflows/publish.yml +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/.gitignore +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/CLAUDE.md +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/LICENSE +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/README.md +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/design.json +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/pyproject.toml +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/requirements.json +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/__init__.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/__main__.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/blocks.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/emit.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/layout.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/parser.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/py.typed +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/code.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_class.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_er.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_state.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/table.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/text.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/src/termrender/style.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/__init__.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_charts.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_cli_contract.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_column_alignment.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_diff.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_inline_badge.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_linebreak.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_class.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_state.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_myst_gaps.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_stat.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_tasklist.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_timeline.py +0 -0
- {termrender-4.10.0 → termrender-4.10.2}/tests/test_variable_colons.py +0 -0
- {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.
|
|
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
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
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
|
|
1491
|
+
def _spread_group_anchors(
|
|
1468
1492
|
rects: dict[str, BoxRect],
|
|
1469
1493
|
direction: Direction,
|
|
1470
1494
|
edges: list[FlowEdge],
|
|
1471
|
-
|
|
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
|
-
"""
|
|
1474
|
-
|
|
1475
|
-
side)``
|
|
1476
|
-
spread
|
|
1477
|
-
:func:`_side_interior_span`) — but
|
|
1478
|
-
|
|
1479
|
-
``
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1798
|
-
|
|
1799
|
-
:func:`_forward_exit`/:func:`
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1834
|
-
|
|
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(
|
|
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
|
|
199
|
-
# mermaid_flow_layout.
|
|
200
|
-
#
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|