termrender 4.8.0__tar.gz → 4.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {termrender-4.8.0 → termrender-4.9.1}/CHANGELOG.md +60 -0
  2. {termrender-4.8.0 → termrender-4.9.1}/PKG-INFO +1 -1
  3. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_class.py +0 -13
  4. termrender-4.9.1/src/termrender/renderers/mermaid_er.py +388 -0
  5. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_layout.py +286 -21
  6. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_class.py +13 -0
  7. termrender-4.9.1/tests/test_mermaid_er.py +302 -0
  8. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_flow_corpus.py +30 -0
  9. {termrender-4.8.0 → termrender-4.9.1}/.github/workflows/publish.yml +0 -0
  10. {termrender-4.8.0 → termrender-4.9.1}/.gitignore +0 -0
  11. {termrender-4.8.0 → termrender-4.9.1}/CLAUDE.md +0 -0
  12. {termrender-4.8.0 → termrender-4.9.1}/LICENSE +0 -0
  13. {termrender-4.8.0 → termrender-4.9.1}/README.md +0 -0
  14. {termrender-4.8.0 → termrender-4.9.1}/design.json +0 -0
  15. {termrender-4.8.0 → termrender-4.9.1}/pyproject.toml +0 -0
  16. {termrender-4.8.0 → termrender-4.9.1}/requirements.json +0 -0
  17. {termrender-4.8.0 → termrender-4.9.1}/scripts/build-mermaid-ascii.sh +0 -0
  18. {termrender-4.8.0 → termrender-4.9.1}/scripts/mermaid_flow_parity.py +0 -0
  19. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/CLAUDE.md +0 -0
  20. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/__init__.py +0 -0
  21. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/__main__.py +0 -0
  22. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  23. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/_mermaid_bin.py +0 -0
  24. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/blocks.py +0 -0
  25. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/emit.py +0 -0
  26. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/layout.py +0 -0
  27. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/parser.py +0 -0
  28. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/py.typed +0 -0
  29. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/CLAUDE.md +0 -0
  30. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/__init__.py +0 -0
  31. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/borders.py +0 -0
  32. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/charts.py +0 -0
  33. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/code.py +0 -0
  34. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/columns.py +0 -0
  35. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/diff.py +0 -0
  36. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/divider.py +0 -0
  37. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid.py +0 -0
  38. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow.py +0 -0
  39. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  40. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  41. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_gantt.py +0 -0
  42. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_journey.py +0 -0
  43. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  44. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_pie.py +0 -0
  45. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
  46. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
  47. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_state.py +0 -0
  48. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
  49. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/panel.py +0 -0
  50. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/quote.py +0 -0
  51. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/stat.py +0 -0
  52. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/table.py +0 -0
  53. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/text.py +0 -0
  54. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/timeline.py +0 -0
  55. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/renderers/tree.py +0 -0
  56. {termrender-4.8.0 → termrender-4.9.1}/src/termrender/style.py +0 -0
  57. {termrender-4.8.0 → termrender-4.9.1}/tests/__init__.py +0 -0
  58. {termrender-4.8.0 → termrender-4.9.1}/tests/test_charts.py +0 -0
  59. {termrender-4.8.0 → termrender-4.9.1}/tests/test_cli_contract.py +0 -0
  60. {termrender-4.8.0 → termrender-4.9.1}/tests/test_column_alignment.py +0 -0
  61. {termrender-4.8.0 → termrender-4.9.1}/tests/test_diff.py +0 -0
  62. {termrender-4.8.0 → termrender-4.9.1}/tests/test_inline_badge.py +0 -0
  63. {termrender-4.8.0 → termrender-4.9.1}/tests/test_linebreak.py +0 -0
  64. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_compat.py +0 -0
  65. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_dispatch.py +0 -0
  66. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_flow.py +0 -0
  67. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_flow_layout.py +0 -0
  68. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_flow_model.py +0 -0
  69. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_flow_parser.py +0 -0
  70. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_flow_shapes.py +0 -0
  71. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_gantt.py +0 -0
  72. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_journey.py +0 -0
  73. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_mindmap.py +0 -0
  74. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_pie.py +0 -0
  75. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_sequence.py +0 -0
  76. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_state.py +0 -0
  77. {termrender-4.8.0 → termrender-4.9.1}/tests/test_mermaid_timeline.py +0 -0
  78. {termrender-4.8.0 → termrender-4.9.1}/tests/test_myst_gaps.py +0 -0
  79. {termrender-4.8.0 → termrender-4.9.1}/tests/test_stat.py +0 -0
  80. {termrender-4.8.0 → termrender-4.9.1}/tests/test_tasklist.py +0 -0
  81. {termrender-4.8.0 → termrender-4.9.1}/tests/test_timeline.py +0 -0
  82. {termrender-4.8.0 → termrender-4.9.1}/tests/test_variable_colons.py +0 -0
  83. {termrender-4.8.0 → termrender-4.9.1}/uv.lock +0 -0
@@ -1,6 +1,66 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.9.1 (2026-07-07)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid-flow**: Stop dropping edge labels/markers on a shared exit anchor
9
+ ([`552d3d3`](https://github.com/crouton-labs/termrender/commit/552d3d3deef061e23c5b894852ae024065366eb8))
10
+
11
+ When 2+ edges leave one node through the router's single fixed exit anchor (e.g. two forward edges
12
+ in LR/RL, or a diamond node's right side), two defects followed:
13
+
14
+ - Label drop: the shared first segment of each edge's Z-path could tie in length with each edge's
15
+ own distinguishing segment further along - routine in LR/RL, where a node's fan-out run and its
16
+ branch runs often measure the same few cells. _longest_segment's old first-wins tie-break always
17
+ picked that shared segment for both edges' labels, so the second label landed on cells the first
18
+ already claimed and silently vanished. Fixed by breaking ties toward the *last* segment instead -
19
+ the segment nearest each edge's own destination, which is never shared. TB is unaffected (its
20
+ branch runs are typically much longer than the shared trunk, so no tie exists there to begin
21
+ with).
22
+
23
+ - Marker drop: a source-side arrow-kind marker glyph (UML composition and aggregation diamonds)
24
+ drawn at the shared anchor cell survived only for the last-drawn edge. Fixed via a new pre-pass,
25
+ _allocate_edge_anchors, that spreads a group's exit points along the node's side - but only when
26
+ 2+ of that group's edges actually carry a source-side marker. Plain, markerless fan-outs (by far
27
+ the common case) keep sharing one exit cell, since that's what makes draw_segment's junction
28
+ bitmask resolve into a single clean tee rather than two disjoint stubs - spreading those would
29
+ only change where the fan visually splits, not fix anything, and would break the existing
30
+ trunk-then-tee golden output for every unlabeled fan-out/merge case.
31
+
32
+ Anchor spreading is shape-aware for NodeShape.DIAMOND (the only shape whose left/right sides aren't
33
+ straight across their full bounding-rect span) so a spread point never lands in the diamond's
34
+ tapered corner region.
35
+
36
+ Removes the "known limitation" paragraph from mermaid_class.py's docstring now that the
37
+ composition/aggregation marker case it described is fixed.
38
+
39
+ Goldens touched: added test_lr_fan_out_with_labels_both_present_golden (new - pins the fixed LR
40
+ output, previously buggy/missing) and test_two_source_side_markers_from_one_class_both_survive
41
+ (new). No existing golden was changed - the fan-out/merge goldens (test_fan_out_golden,
42
+ test_multi_parent_dag_golden, test_td_direction_golden, test_lr_direction_golden) are
43
+ byte-for-byte unchanged because none of their edges carry a source-side marker, so the new
44
+ anchor-spread never triggers for them.
45
+
46
+
47
+ ## v4.9.0 (2026-07-07)
48
+
49
+ ### Features
50
+
51
+ - **mermaid-er**: Native erDiagram renderer
52
+ ([`caae993`](https://github.com/crouton-labs/termrender/commit/caae9930b6a996e33b401dc773c5a9aae01b1529))
53
+
54
+ Parses mermaid erDiagram source (entity attribute blocks with PK/FK/UK markers and quoted comments,
55
+ bare entities, the full crow's-foot cardinality grammar in both writing directions, identifying/
56
+ non-identifying relationship lines, entity/attribute aliasing and quoted names) into the shared
57
+ FlowGraph model and hands it to the existing layout_flowgraph engine (compartmented boxes, edge
58
+ labels). Never crashes: unparseable input degrades to a raw echo with no box-drawing glyphs,
59
+ matching mermaid_class.py's contract.
60
+
61
+ Not wired into mermaid.py's dispatcher (later phase). Read-only against mermaid_flow*.py.
62
+
63
+
4
64
  ## v4.8.0 (2026-07-07)
5
65
 
6
66
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.8.0
3
+ Version: 4.9.1
4
4
  Summary: Rich terminal rendering of directive-flavored markdown
5
5
  Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
6
6
  Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
@@ -70,19 +70,6 @@ Known degradations (by design, not bugs)
70
70
  - ``note`` statements, `<<...>>` generic constraints beyond the ``~T~``
71
71
  substitution, and class-diagram namespaces are not recognized — lines
72
72
  using them are silently dropped (best-effort parsing, never raised).
73
- - A relation's *source*-side marker (inheritance/composition/aggregation's
74
- glyph on the "whole"/parent class) is drawn at that class's single
75
- shared exit anchor for its rank-flow direction. When one class has
76
- several outgoing relations that each carry a source-side marker (e.g. a
77
- class that both composes one collaborator and aggregates another), all
78
- of them exit through that same anchor cell and only the last-drawn
79
- marker glyph survives there — topology and line routing stay correct
80
- for every edge, but the specific marker glyph shown at that shared point
81
- reflects only one of the colliding relations. Rare in small diagrams
82
- (most classes have at most one source-marked outgoing relation);
83
- accepted the same way the engine already accepts dense-graph line
84
- crossings, rather than reworking shared anchor allocation across the
85
- underlying flowchart engine.
86
73
  - See ``mermaid_flow_layout.py``'s docstring for the inherited engine
87
74
  degradations (dense-graph crossings, CJK wrap width, minimum-box-size
88
75
  cosmetics) — this renderer's output goes through the same rasterizer and
@@ -0,0 +1,388 @@
1
+ """Native renderer for mermaid ``erDiagram`` sources.
2
+
3
+ Standalone module: exposes a single pure function, :func:`render_er`. Not
4
+ wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned phase)
5
+ — nothing in this package imports it. Own parser, own tests, no dependency
6
+ on ``Block`` or the render pipeline, matching the shape of
7
+ ``mermaid_class.py`` and ``mermaid_flow.py``.
8
+
9
+ This module does no layout or rasterization of its own. It parses mermaid
10
+ ``erDiagram`` source into the *same* :class:`~termrender.renderers.
11
+ mermaid_flow_model.FlowGraph` the native flowchart engine consumes — each
12
+ ER entity becomes a :class:`~termrender.renderers.mermaid_flow_model.
13
+ FlowNode` (a compartmented box when the entity has an attribute block, a
14
+ plain single-label box otherwise) and each ER relationship becomes a
15
+ :class:`~termrender.renderers.mermaid_flow_model.FlowEdge` (a plain,
16
+ headless line — ER cardinality is drawn as edge-label text, not an
17
+ arrowhead glyph family) — then hands the graph to
18
+ :func:`~termrender.renderers.mermaid_flow_layout.layout_flowgraph`, which
19
+ owns grandalf layout, box rasterization, and orthogonal edge routing. See
20
+ that module's docstring for how compartments render.
21
+
22
+ Grammar supported
23
+ ------------------
24
+ - Header: ``erDiagram``. Diagram direction is always top-to-bottom
25
+ (mermaid's erDiagram has no ``direction`` statement of its own, unlike
26
+ flowcharts/class diagrams).
27
+ - Entity blocks: ``ENTITY { ... }``, either as a single line
28
+ (``CUSTOMER { string name PK }``) or spanning multiple lines up to a
29
+ standalone ``}``. Each attribute line is ``type name [PK|FK|UK[, ...]]
30
+ ["comment"]`` — type and name are required, the key marker(s) and the
31
+ trailing quoted comment are both optional. The comment is parsed (so it
32
+ doesn't corrupt the match) but dropped from the rendered row — see
33
+ *Known degradations*.
34
+ - Bare entity declarations: ``ENTITY`` on its own line, and entities
35
+ referenced only from a relationship line (never given a block) — both
36
+ render as a plain single-label box (no compartments), matching
37
+ ``mermaid_class.py``'s bare-class handling.
38
+ - Entity/attribute aliases: ``p[Person]`` (id ``p``, displayed as
39
+ ``Person``) and quoted names/ids containing spaces or punctuation
40
+ (``"driver's license"``, ``"Order Item"[OI]``) are recognized wherever
41
+ an entity token or an attribute name appears; the quotes are stripped
42
+ for display, the unquoted text is the graph key.
43
+ - Relationships: ``ENTITY1 <left-card><line><right-card> ENTITY2 :
44
+ label``. ``<line>`` is ``--`` (identifying) or ``..`` (non-identifying);
45
+ identifying relationships render as a solid line, non-identifying as a
46
+ dashed line (:class:`EdgeStyle.DOTTED`, same glyphs the class-diagram
47
+ renderer uses for realization/dependency). ``<left-card>``/
48
+ ``<right-card>`` are mermaid's crow's-foot pairs read next to their
49
+ adjacent entity — ``||`` exactly one, ``o|``/``|o`` zero-or-one,
50
+ ``}|``/``|{`` one-or-more, ``}o``/``o{`` zero-or-more (both writing
51
+ directions accepted, per mermaid's own grammar) — translated to text
52
+ markers ``1``, ``0..1``, ``1..*``, ``0..*`` and combined with the
53
+ optional ``: label`` into one string (``"1 places 0..*"``), centered on
54
+ the edge by the engine. Relationship lines never draw an arrowhead
55
+ (``dst_arrow``/``src_arrow`` both ``False``) — ER cardinality is
56
+ conveyed entirely by the label text, not by a glyph family, since crow's
57
+ foot notation has no equivalent among the engine's existing arrow-kind
58
+ glyphs (see *Known degradations*).
59
+ - ``%%`` comments are dropped.
60
+
61
+ Never crashes: any unparseable input (missing header, or a parse/layout
62
+ exception) degrades to a **raw echo** of the source lines with no
63
+ box-drawing/geometric glyphs (same contract, same glyph ranges, as
64
+ ``mermaid_flow.py`` and ``mermaid_class.py`` — see those modules'
65
+ docstrings for why this exact degradation matters to the crouter attach
66
+ viewer).
67
+
68
+ Known degradations (by design, not bugs)
69
+ -----------------------------------------
70
+ - Cardinality and the relation label are combined into a single string
71
+ centered on the edge (``"1 places 0..*"``), not three independently
72
+ placed annotations (one marker pinned at each endpoint plus a centered
73
+ label, the dedicated-ER-tool convention) — the underlying engine places
74
+ one label per edge, same limitation ``mermaid_class.py`` documents for
75
+ UML cardinalities.
76
+ - No crow's-foot glyphs are drawn at the endpoints; cardinality is text
77
+ only. The engine's arrow-kind extension point supports UML triangle/
78
+ diamond families, not a crow's-foot family, and adding one is out of
79
+ scope for this renderer (an engine change, not a parser change).
80
+ - Attribute comments (the trailing quoted string in an attribute line)
81
+ are parsed — so they don't break the match or leak into the name/key
82
+ columns — but dropped entirely from the rendered row rather than
83
+ wrapped or truncated onto a second line.
84
+ - An entity declared with an empty block (``ENTITY { }``) still gets a
85
+ two-compartment box (title + a single blank attribute row) rather than
86
+ collapsing to a plain box — mirrors ``mermaid_class.py``'s
87
+ always-show-the-band-if-a-block-was-declared behavior for classes with
88
+ an empty fields/methods section.
89
+ - See ``mermaid_flow_layout.py``'s docstring for the inherited engine
90
+ degradations (dense-graph crossings, CJK wrap width, minimum-box-size
91
+ cosmetics) — this renderer's output goes through the same rasterizer
92
+ and router as flowcharts and class diagrams.
93
+ """
94
+
95
+ from __future__ import annotations
96
+
97
+ import re
98
+
99
+ from termrender.renderers.mermaid_flow_layout import layout_flowgraph
100
+ from termrender.renderers.mermaid_flow_model import (
101
+ Direction,
102
+ EdgeStyle,
103
+ FlowEdge,
104
+ FlowGraph,
105
+ FlowNode,
106
+ NodeShape,
107
+ )
108
+ from termrender.renderers.mermaid_prelude import strip_prelude_lines
109
+
110
+ __all__ = ["render_er", "ERDiagramError"]
111
+
112
+
113
+ class ERDiagramError(Exception):
114
+ """Raised when source cannot be parsed as a mermaid ER diagram at all."""
115
+
116
+
117
+ # --------------------------------------------------------------------------
118
+ # Grammar
119
+ # --------------------------------------------------------------------------
120
+
121
+ _HEADER_RE = re.compile(r"^erDiagram\b", re.IGNORECASE)
122
+ _COMMENT_RE = re.compile(r"^%%")
123
+
124
+ # An entity token: a quoted name (optionally aliased, `"Order Item"[OI]`),
125
+ # or a bare run of non-space/non-brace characters (`CUSTOMER`, `p[Person]`,
126
+ # `LINE-ITEM`). Used for bare declarations, block openers, and relationship
127
+ # endpoints alike (see `_parse_entity_token`).
128
+ _ENTITY_TOKEN = r'"[^"]*"(?:\[[^\]]*\])?|[^\s{]+'
129
+
130
+ _ENTITY_TOKEN_RE = re.compile(r'^(?P<id>"[^"]*"|[^\[\s]+)(?:\[(?P<alias>[^\]]*)\])?$')
131
+ _BLOCK_OPEN_RE = re.compile(rf"^(?P<idtok>{_ENTITY_TOKEN})\s*\{{(?P<inline>.*)$")
132
+ _BARE_ENTITY_RE = re.compile(rf"^(?P<idtok>{_ENTITY_TOKEN})\s*$")
133
+
134
+ # Attribute line: `type name [PK|FK|UK[, ...]] ["comment"]`. The comment is
135
+ # matched (so it can't corrupt the name/keys columns) but never rendered.
136
+ _ATTR_RE = re.compile(
137
+ r'^(?P<type>\S+)\s+(?P<name>"[^"]*"|\S+)'
138
+ r"(?:\s+(?P<keys>(?:PK|FK|UK)(?:\s*,\s*(?:PK|FK|UK))*))?"
139
+ r'(?:\s+"[^"]*")?\s*$',
140
+ re.IGNORECASE,
141
+ )
142
+
143
+ # Relationship operator: a left crow's-foot pair, a >=2-run dash/dot core
144
+ # (dash = identifying/solid, dot = non-identifying/dashed), a right
145
+ # crow's-foot pair. Searched against a quote-masked copy of the line (see
146
+ # `_mask_quotes`) so a quoted entity/attribute name can never be mistaken
147
+ # for the operator.
148
+ _LEFT_CARD = r"\|o|\|\||\}o|\}\|"
149
+ _RIGHT_CARD = r"o\||\|\||o\{|\|\{"
150
+ _REL_OP_RE = re.compile(rf"(?P<left>{_LEFT_CARD})(?P<mid>--|\.\.)(?P<right>{_RIGHT_CARD})")
151
+ _REL_LEFT_RE = re.compile(rf"^\s*(?P<idtok>{_ENTITY_TOKEN})\s*$")
152
+ _REL_RIGHT_RE = re.compile(rf"^\s*(?P<idtok>{_ENTITY_TOKEN})\s*(?::\s*(?P<label>.*))?$")
153
+
154
+ _GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
155
+
156
+ # Crow's-foot pair -> cardinality text, read from mermaid's own vocabulary
157
+ # (both writing directions map to the same text on the side they sit next
158
+ # to).
159
+ _CARD_LEFT = {"|o": "0..1", "||": "1", "}o": "0..*", "}|": "1..*"}
160
+ _CARD_RIGHT = {"o|": "0..1", "||": "1", "o{": "0..*", "|{": "1..*"}
161
+
162
+
163
+ class _Entity:
164
+ """Mutable parse-time record for one ER entity — converted to a
165
+ :class:`FlowNode` at the end of parsing (see :func:`_build_graph`)."""
166
+
167
+ __slots__ = ("id", "display_name", "attrs", "has_block")
168
+
169
+ def __init__(self, id_: str, display_name: str) -> None:
170
+ self.id = id_
171
+ self.display_name = display_name
172
+ self.attrs: list[str] = []
173
+ self.has_block = False
174
+
175
+
176
+ def _unquote(s: str) -> str:
177
+ if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
178
+ return s[1:-1]
179
+ return s
180
+
181
+
182
+ def _mask_quotes(line: str) -> str:
183
+ """Replace every quoted span with same-length filler so a quoted entity
184
+ or attribute name can never be mistaken for the relation operator when
185
+ searching for it (see `_REL_OP_RE`); positions in the masked string
186
+ stay valid indices into the original line."""
187
+ return re.sub(r'"[^"]*"', lambda m: "Q" * len(m.group(0)), line)
188
+
189
+
190
+ def _parse_entity_token(token: str) -> tuple[str, str | None]:
191
+ """Split an entity token into its graph-key id and optional display
192
+ alias: ``CUSTOMER`` -> (``CUSTOMER``, None); ``p[Person]`` -> (``p``,
193
+ ``Person``); ``"Order Item"[OI]`` -> (``Order Item``, ``OI``)."""
194
+ m = _ENTITY_TOKEN_RE.match(token.strip())
195
+ if not m:
196
+ return _unquote(token.strip()), None
197
+ alias = m.group("alias")
198
+ return _unquote(m.group("id")), (alias.strip() if alias else None)
199
+
200
+
201
+ def _get_or_create(entities: dict[str, _Entity], token: str) -> _Entity:
202
+ eid, alias = _parse_entity_token(token)
203
+ ent = entities.get(eid)
204
+ if ent is None:
205
+ ent = _Entity(eid, alias or eid)
206
+ entities[eid] = ent
207
+ elif alias:
208
+ ent.display_name = alias
209
+ return ent
210
+
211
+
212
+ def _format_attr_row(m: re.Match[str]) -> str:
213
+ row = f"{m.group('type')} {_unquote(m.group('name'))}"
214
+ keys = m.group("keys")
215
+ if keys:
216
+ norm = re.sub(r"\s*,\s*", ", ", keys.strip()).upper()
217
+ row += f" {norm}"
218
+ return row
219
+
220
+
221
+ def _consume_attrs(entity: _Entity, text: str) -> None:
222
+ """Split ``text`` (one physical line, or the inline body of an
223
+ ``ENTITY { ... }`` opener/closer) on ``;`` into individual attribute
224
+ statements and file each into ``entity``. An attribute that doesn't
225
+ match the ``type name [keys] [comment]`` grammar is dropped silently
226
+ (best-effort, never raised)."""
227
+ for part in text.split(";"):
228
+ part = part.strip()
229
+ if not part:
230
+ continue
231
+ m = _ATTR_RE.match(part)
232
+ if m:
233
+ entity.attrs.append(_format_attr_row(m))
234
+
235
+
236
+ def _try_relation(
237
+ line: str, entities: dict[str, _Entity], edges: list[FlowEdge]
238
+ ) -> bool:
239
+ masked = _mask_quotes(line)
240
+ m = _REL_OP_RE.search(masked)
241
+ if not m:
242
+ return False
243
+
244
+ left_text, right_text = line[: m.start()], line[m.end() :]
245
+ lm = _REL_LEFT_RE.match(left_text)
246
+ rm = _REL_RIGHT_RE.match(right_text)
247
+ if not lm or not rm:
248
+ return False
249
+
250
+ src = _get_or_create(entities, lm.group("idtok"))
251
+ dst = _get_or_create(entities, rm.group("idtok"))
252
+
253
+ style = EdgeStyle.DOTTED if m.group("mid") == ".." else EdgeStyle.SOLID
254
+ src_card = _CARD_LEFT.get(m.group("left"))
255
+ dst_card = _CARD_RIGHT.get(m.group("right"))
256
+ label_text = rm.group("label")
257
+ parts = [p for p in (src_card, label_text, dst_card) if p]
258
+ label = " ".join(parts) if parts else None
259
+
260
+ edges.append(
261
+ FlowEdge(
262
+ src=src.id,
263
+ dst=dst.id,
264
+ style=style,
265
+ label=label,
266
+ dst_arrow=False,
267
+ src_arrow=False,
268
+ )
269
+ )
270
+ return True
271
+
272
+
273
+ def _build_graph(source: str) -> FlowGraph:
274
+ lines = source.splitlines()
275
+ sniff_lines = strip_prelude_lines(lines)
276
+ first = next((line.strip() for line in sniff_lines if line.strip()), "")
277
+ if not _HEADER_RE.match(first):
278
+ raise ERDiagramError(
279
+ "not a mermaid ER diagram: source must start with 'erDiagram'"
280
+ )
281
+
282
+ entities: dict[str, _Entity] = {}
283
+ edges: list[FlowEdge] = []
284
+ seen_header = False
285
+ block_entity: _Entity | None = None
286
+
287
+ for raw_line in lines:
288
+ line = raw_line.strip()
289
+ if not line:
290
+ continue
291
+
292
+ if block_entity is not None:
293
+ if line.endswith("}"):
294
+ _consume_attrs(block_entity, line[:-1])
295
+ block_entity = None
296
+ else:
297
+ _consume_attrs(block_entity, line)
298
+ continue
299
+
300
+ if not seen_header:
301
+ if _HEADER_RE.match(line):
302
+ seen_header = True
303
+ continue
304
+
305
+ if _COMMENT_RE.match(line):
306
+ continue
307
+
308
+ om = _BLOCK_OPEN_RE.match(line)
309
+ if om:
310
+ ent = _get_or_create(entities, om.group("idtok"))
311
+ ent.has_block = True
312
+ body = om.group("inline").rstrip()
313
+ if body.endswith("}"):
314
+ _consume_attrs(ent, body[:-1])
315
+ else:
316
+ _consume_attrs(ent, body)
317
+ block_entity = ent
318
+ continue
319
+
320
+ if _try_relation(line, entities, edges):
321
+ continue
322
+
323
+ bm = _BARE_ENTITY_RE.match(line)
324
+ if bm:
325
+ _get_or_create(entities, bm.group("idtok"))
326
+ continue
327
+
328
+ # Unrecognized line: consumed silently, best-effort.
329
+
330
+ if not seen_header:
331
+ raise ERDiagramError(
332
+ "not a mermaid ER diagram: source must start with 'erDiagram'"
333
+ )
334
+
335
+ nodes: list[FlowNode] = []
336
+ for ent in entities.values():
337
+ compartments = [[ent.display_name], ent.attrs or [""]] if ent.has_block else None
338
+ nodes.append(
339
+ FlowNode(
340
+ id=ent.id,
341
+ label=ent.display_name,
342
+ shape=NodeShape.RECT,
343
+ compartments=compartments,
344
+ )
345
+ )
346
+
347
+ return FlowGraph(direction=Direction.TB, nodes=nodes, edges=edges, subgraphs=[])
348
+
349
+
350
+ def _raw_echo(source: str) -> list[str]:
351
+ return [_GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()]
352
+
353
+
354
+ def render_er(source: str, width: int) -> list[str]:
355
+ """Render a mermaid ``erDiagram`` source to unicode lines.
356
+
357
+ Pure and total: never raises. Degrades to a raw echo of ``source``
358
+ (see the module docstring's "Never crashes" note) when the source
359
+ isn't an ER diagram at all, parses to zero entities, or an unexpected
360
+ exception escapes layout.
361
+
362
+ Args:
363
+ source: The mermaid fence body (with or without surrounding fence
364
+ markers — only the text between them).
365
+ width: Advisory terminal width — unused, like the flowchart and
366
+ class-diagram renderers; this function sizes to content.
367
+
368
+ Returns:
369
+ Rendered lines on success (guaranteed to contain box-drawing
370
+ glyphs), or a raw echo of ``source`` on any degradation path.
371
+ """
372
+ try:
373
+ graph = _build_graph(source)
374
+ except ERDiagramError:
375
+ return _raw_echo(source)
376
+
377
+ if not graph.nodes:
378
+ return _raw_echo(source)
379
+
380
+ try:
381
+ lines = layout_flowgraph(graph, width)
382
+ except Exception:
383
+ return _raw_echo(source)
384
+
385
+ if not lines:
386
+ return _raw_echo(source)
387
+
388
+ return lines