termrender 4.7.0__tar.gz → 4.9.0__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.7.0 → termrender-4.9.0}/CHANGELOG.md +53 -0
  2. {termrender-4.7.0 → termrender-4.9.0}/PKG-INFO +1 -1
  3. termrender-4.9.0/src/termrender/renderers/mermaid_er.py +388 -0
  4. termrender-4.9.0/src/termrender/renderers/mermaid_state.py +519 -0
  5. termrender-4.9.0/tests/test_mermaid_er.py +302 -0
  6. termrender-4.9.0/tests/test_mermaid_state.py +486 -0
  7. {termrender-4.7.0 → termrender-4.9.0}/.github/workflows/publish.yml +0 -0
  8. {termrender-4.7.0 → termrender-4.9.0}/.gitignore +0 -0
  9. {termrender-4.7.0 → termrender-4.9.0}/CLAUDE.md +0 -0
  10. {termrender-4.7.0 → termrender-4.9.0}/LICENSE +0 -0
  11. {termrender-4.7.0 → termrender-4.9.0}/README.md +0 -0
  12. {termrender-4.7.0 → termrender-4.9.0}/design.json +0 -0
  13. {termrender-4.7.0 → termrender-4.9.0}/pyproject.toml +0 -0
  14. {termrender-4.7.0 → termrender-4.9.0}/requirements.json +0 -0
  15. {termrender-4.7.0 → termrender-4.9.0}/scripts/build-mermaid-ascii.sh +0 -0
  16. {termrender-4.7.0 → termrender-4.9.0}/scripts/mermaid_flow_parity.py +0 -0
  17. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/CLAUDE.md +0 -0
  18. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/__init__.py +0 -0
  19. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/__main__.py +0 -0
  20. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  21. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/_mermaid_bin.py +0 -0
  22. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/blocks.py +0 -0
  23. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/emit.py +0 -0
  24. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/layout.py +0 -0
  25. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/parser.py +0 -0
  26. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/py.typed +0 -0
  27. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/CLAUDE.md +0 -0
  28. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/__init__.py +0 -0
  29. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/borders.py +0 -0
  30. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/charts.py +0 -0
  31. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/code.py +0 -0
  32. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/columns.py +0 -0
  33. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/diff.py +0 -0
  34. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/divider.py +0 -0
  35. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid.py +0 -0
  36. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_class.py +0 -0
  37. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow.py +0 -0
  38. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_layout.py +0 -0
  39. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  40. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  41. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  42. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_journey.py +0 -0
  43. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  44. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  45. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
  46. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
  47. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
  48. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/panel.py +0 -0
  49. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/quote.py +0 -0
  50. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/stat.py +0 -0
  51. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/table.py +0 -0
  52. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/text.py +0 -0
  53. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/timeline.py +0 -0
  54. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/renderers/tree.py +0 -0
  55. {termrender-4.7.0 → termrender-4.9.0}/src/termrender/style.py +0 -0
  56. {termrender-4.7.0 → termrender-4.9.0}/tests/__init__.py +0 -0
  57. {termrender-4.7.0 → termrender-4.9.0}/tests/test_charts.py +0 -0
  58. {termrender-4.7.0 → termrender-4.9.0}/tests/test_cli_contract.py +0 -0
  59. {termrender-4.7.0 → termrender-4.9.0}/tests/test_column_alignment.py +0 -0
  60. {termrender-4.7.0 → termrender-4.9.0}/tests/test_diff.py +0 -0
  61. {termrender-4.7.0 → termrender-4.9.0}/tests/test_inline_badge.py +0 -0
  62. {termrender-4.7.0 → termrender-4.9.0}/tests/test_linebreak.py +0 -0
  63. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_class.py +0 -0
  64. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_compat.py +0 -0
  65. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_dispatch.py +0 -0
  66. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow.py +0 -0
  67. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_corpus.py +0 -0
  68. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_layout.py +0 -0
  69. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_model.py +0 -0
  70. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_parser.py +0 -0
  71. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_flow_shapes.py +0 -0
  72. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_gantt.py +0 -0
  73. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_journey.py +0 -0
  74. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_mindmap.py +0 -0
  75. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_pie.py +0 -0
  76. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_sequence.py +0 -0
  77. {termrender-4.7.0 → termrender-4.9.0}/tests/test_mermaid_timeline.py +0 -0
  78. {termrender-4.7.0 → termrender-4.9.0}/tests/test_myst_gaps.py +0 -0
  79. {termrender-4.7.0 → termrender-4.9.0}/tests/test_stat.py +0 -0
  80. {termrender-4.7.0 → termrender-4.9.0}/tests/test_tasklist.py +0 -0
  81. {termrender-4.7.0 → termrender-4.9.0}/tests/test_timeline.py +0 -0
  82. {termrender-4.7.0 → termrender-4.9.0}/tests/test_variable_colons.py +0 -0
  83. {termrender-4.7.0 → termrender-4.9.0}/uv.lock +0 -0
@@ -1,6 +1,59 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.9.0 (2026-07-07)
5
+
6
+ ### Features
7
+
8
+ - **mermaid-er**: Native erDiagram renderer
9
+ ([`caae993`](https://github.com/crouton-labs/termrender/commit/caae9930b6a996e33b401dc773c5a9aae01b1529))
10
+
11
+ Parses mermaid erDiagram source (entity attribute blocks with PK/FK/UK markers and quoted comments,
12
+ bare entities, the full crow's-foot cardinality grammar in both writing directions, identifying/
13
+ non-identifying relationship lines, entity/attribute aliasing and quoted names) into the shared
14
+ FlowGraph model and hands it to the existing layout_flowgraph engine (compartmented boxes, edge
15
+ labels). Never crashes: unparseable input degrades to a raw echo with no box-drawing glyphs,
16
+ matching mermaid_class.py's contract.
17
+
18
+ Not wired into mermaid.py's dispatcher (later phase). Read-only against mermaid_flow*.py.
19
+
20
+
21
+ ## v4.8.0 (2026-07-07)
22
+
23
+ ### Features
24
+
25
+ - Native mermaid stateDiagram renderer on the flowchart engine
26
+ ([`ff4fa87`](https://github.com/crouton-labs/termrender/commit/ff4fa87d275d0e18f64e386c16e13e237ad99923))
27
+
28
+ Adds src/termrender/renderers/mermaid_state.py exposing render_state(source, width) -> list[str] as
29
+ a thin adapter: parses stateDiagram/stateDiagram-v2 grammar into the existing flowchart engine's
30
+ own FlowGraph/FlowNode/FlowEdge/Subgraph model and renders through layout_flowgraph directly
31
+ (grandalf layout, box rasterizer, orthogonal router, subgraph frames), reusing that engine's
32
+ degradation contract unchanged.
33
+
34
+ Covers: A --> B / A --> B : event transitions; [*] as per-scope start/end pseudo-states (distinct
35
+ compact glyph markers, shared across every [*] reference in one scope); state "Long name" as s1
36
+ aliases; state X { ... } composite states as nested subgraph frames (a composite referenced
37
+ directly by an external transition gets its own proxy box, since the engine has no edge-to-frame
38
+ anchor); <<choice>> as a diamond, <<fork>>/<<join>> degrading to the engine's plain small rect;
39
+ direction LR/TD/RL/BT; note left/right of X (inline and multi-line note/end note forms) attached
40
+ via a headless dotted edge so text is never dropped; %% comments skipped anywhere including inside
41
+ note blocks; -- / --- concurrency separators flattened.
42
+
43
+ Unwired: not imported by mermaid.py's dispatcher (later phase). Only touches new files; the
44
+ flowchart engine files remain untouched (read-only, per a concurrent sibling's ownership).
45
+
46
+ tests/test_mermaid_state.py: 24 tests asserting real topology/geometry (row/column ordering, frame
47
+ containment, distinct start/end glyphs, diamond-shape glyphs, label text) across a simple
48
+ start/end machine, labeled transitions, aliases, choice/fork/join, composite + nested composite
49
+ states, inline/multi-line notes, direction LR, comments, and the degradation contract
50
+ (header-missing, zero-node, and crash-guard echo paths, each pinned to the same three-condition
51
+ rule the flowchart engine's own tests already establish — best-effort recoverable body-level
52
+ glitches, like a stray '}' or an unterminated composite/note, render real content rather than
53
+ forcing an echo, matching mermaid_flow_parser.py's documented 'auto-closed rather than dropped' /
54
+ 'consumed silently, best-effort' policy).
55
+
56
+
4
57
  ## v4.7.0 (2026-07-07)
5
58
 
6
59
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.7.0
3
+ Version: 4.9.0
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
@@ -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