termrender 4.3.0__tar.gz → 4.3.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 (71) hide show
  1. {termrender-4.3.0 → termrender-4.3.1}/CHANGELOG.md +55 -0
  2. {termrender-4.3.0 → termrender-4.3.1}/PKG-INFO +1 -1
  3. termrender-4.3.1/src/termrender/renderers/mermaid_gantt.py +459 -0
  4. termrender-4.3.1/src/termrender/renderers/mermaid_pie.py +128 -0
  5. termrender-4.3.1/tests/test_mermaid_gantt.py +333 -0
  6. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_pie.py +63 -2
  7. termrender-4.3.0/src/termrender/renderers/mermaid_gantt.py +0 -222
  8. termrender-4.3.0/src/termrender/renderers/mermaid_pie.py +0 -84
  9. termrender-4.3.0/tests/test_mermaid_gantt.py +0 -154
  10. {termrender-4.3.0 → termrender-4.3.1}/.github/workflows/publish.yml +0 -0
  11. {termrender-4.3.0 → termrender-4.3.1}/.gitignore +0 -0
  12. {termrender-4.3.0 → termrender-4.3.1}/CLAUDE.md +0 -0
  13. {termrender-4.3.0 → termrender-4.3.1}/LICENSE +0 -0
  14. {termrender-4.3.0 → termrender-4.3.1}/README.md +0 -0
  15. {termrender-4.3.0 → termrender-4.3.1}/design.json +0 -0
  16. {termrender-4.3.0 → termrender-4.3.1}/pyproject.toml +0 -0
  17. {termrender-4.3.0 → termrender-4.3.1}/requirements.json +0 -0
  18. {termrender-4.3.0 → termrender-4.3.1}/scripts/build-mermaid-ascii.sh +0 -0
  19. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/CLAUDE.md +0 -0
  20. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/__init__.py +0 -0
  21. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/__main__.py +0 -0
  22. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  23. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/_mermaid_bin.py +0 -0
  24. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/blocks.py +0 -0
  25. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/emit.py +0 -0
  26. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/layout.py +0 -0
  27. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/parser.py +0 -0
  28. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/py.typed +0 -0
  29. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/CLAUDE.md +0 -0
  30. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/__init__.py +0 -0
  31. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/borders.py +0 -0
  32. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/charts.py +0 -0
  33. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/code.py +0 -0
  34. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/columns.py +0 -0
  35. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/diff.py +0 -0
  36. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/divider.py +0 -0
  37. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid.py +0 -0
  38. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  39. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_journey.py +0 -0
  40. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  41. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
  42. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
  43. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
  44. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/panel.py +0 -0
  45. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/quote.py +0 -0
  46. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/stat.py +0 -0
  47. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/table.py +0 -0
  48. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/text.py +0 -0
  49. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/timeline.py +0 -0
  50. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/tree.py +0 -0
  51. {termrender-4.3.0 → termrender-4.3.1}/src/termrender/style.py +0 -0
  52. {termrender-4.3.0 → termrender-4.3.1}/tests/__init__.py +0 -0
  53. {termrender-4.3.0 → termrender-4.3.1}/tests/test_charts.py +0 -0
  54. {termrender-4.3.0 → termrender-4.3.1}/tests/test_cli_contract.py +0 -0
  55. {termrender-4.3.0 → termrender-4.3.1}/tests/test_column_alignment.py +0 -0
  56. {termrender-4.3.0 → termrender-4.3.1}/tests/test_diff.py +0 -0
  57. {termrender-4.3.0 → termrender-4.3.1}/tests/test_inline_badge.py +0 -0
  58. {termrender-4.3.0 → termrender-4.3.1}/tests/test_linebreak.py +0 -0
  59. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_compat.py +0 -0
  60. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_dispatch.py +0 -0
  61. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_flow_model.py +0 -0
  62. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_journey.py +0 -0
  63. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_mindmap.py +0 -0
  64. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_sequence.py +0 -0
  65. {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_timeline.py +0 -0
  66. {termrender-4.3.0 → termrender-4.3.1}/tests/test_myst_gaps.py +0 -0
  67. {termrender-4.3.0 → termrender-4.3.1}/tests/test_stat.py +0 -0
  68. {termrender-4.3.0 → termrender-4.3.1}/tests/test_tasklist.py +0 -0
  69. {termrender-4.3.0 → termrender-4.3.1}/tests/test_timeline.py +0 -0
  70. {termrender-4.3.0 → termrender-4.3.1}/tests/test_variable_colons.py +0 -0
  71. {termrender-4.3.0 → termrender-4.3.1}/uv.lock +0 -0
@@ -1,6 +1,61 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.3.1 (2026-07-06)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid-gantt**: Close remaining overflow and includes gaps from review
9
+ ([`73a1354`](https://github.com/crouton-labs/termrender/commit/73a135465644073d58e293ed69e6ca968d4f5a05))
10
+
11
+ - _skip_excluded() and the implicit +1-day default end could still raise OverflowError past
12
+ datetime.max; both call sites now catch it and degrade the whole diagram to source instead of
13
+ crashing. - includes weekends was accepted but silently no-op'd (excludes weekends + includes
14
+ weekends still rendered the excluded schedule); includes now only supports explicit YYYY-MM-DD
15
+ dates and rejects weekends as _Unsupported rather than pretending to cancel the exclusion.
16
+
17
+ fix(mermaid-pie): reject invalid trailing header grammar
18
+
19
+ pie bogus (anything after pie/pie showData that isn't a valid inline title) was silently accepted
20
+ and rendered a plausible chart; it now degrades the whole diagram to source, matching the
21
+ strictness already applied to other invalid pie lines.
22
+
23
+ - **mermaid-gantt**: Implement excludes/includes, milestone, until; never-crash on bad
24
+ dateFormat/overflow
25
+ ([`e182ae1`](https://github.com/crouton-labs/termrender/commit/e182ae1346af7fa4a531ad0014878e06caf5304e))
26
+
27
+ Several valid Mermaid gantt constructs rendered false schedules, and malformed input could crash:
28
+
29
+ - A malformed dateFormat (e.g. YYYY-YYYY, whose translated strptime pattern has a duplicate capture
30
+ group) raised re.error instead of being caught; the format is now validated by round-tripping a
31
+ sample date and an invalid format degrades the whole diagram to source. - Huge numeric durations
32
+ (e.g. 1000000000d) overflowed timedelta/datetime arithmetic; overflow is now caught and degrades
33
+ the whole diagram. - excludes weekends / excludes <YYYY-MM-DD> and includes <YYYY-MM-DD> are now
34
+ applied when resolving durations and auto-anchored start dates, instead of being ignored as
35
+ decorative metadata. An excludes/includes form we don't implement (e.g. day names) degrades the
36
+ whole diagram rather than rendering a false schedule. - milestone tasks are now tracked as
37
+ point-in-time markers (rendered as a single diamond marker) instead of ordinary duration spans. -
38
+ until <taskId> is now resolved to the referenced task's start date; an unknown reference degrades
39
+ the whole diagram. - %% comments (whole-line and trailing inline) are stripped before
40
+ tokenization, so a comment containing a colon can no longer be misparsed as a task.
41
+
42
+ - **mermaid-pie**: Degrade to source on invalid grammar, negative or overflowing values
43
+ ([`765f613`](https://github.com/crouton-labs/termrender/commit/765f613d9e1b72ff79e8f0c32ac8d9f9cdceef69))
44
+
45
+ Native pie parsing previously treated any non-matching line as a silent ignorable and accepted
46
+ negative slice values, so malformed input rendered a plausible-but-wrong partial chart instead of
47
+ falling back to source. Arbitrarily long numeric literals could also reach charts._format_value()
48
+ as float('inf') and raise OverflowError.
49
+
50
+ - Invalid grammar (anything beyond header/title/accTitle/accDescr/%% comments/slice lines) now
51
+ discards the whole diagram and falls back to raw source, matching Mermaid's actual pie grammar. -
52
+ Negative slice values are rejected the same way (Mermaid disallows them). - Non-finite values
53
+ (from huge numeric literals) are rejected in parse_pie, and the summed total is re-checked in
54
+ render() as a second guard, both falling back to source instead of crashing. - Slice labels now
55
+ also accept single-quoted strings and escaped quotes inside double-quoted strings, per Mermaid's
56
+ real string grammar.
57
+
58
+
4
59
  ## v4.3.0 (2026-07-06)
5
60
 
6
61
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.3.0
3
+ Version: 4.3.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
@@ -0,0 +1,459 @@
1
+ """Native renderer for mermaid ``gantt`` diagrams.
2
+
3
+ Mermaid gantt grammar (core subset)::
4
+
5
+ gantt
6
+ title A Gantt Diagram
7
+ dateFormat YYYY-MM-DD
8
+ excludes weekends
9
+ section Section A
10
+ Task1 :a1, 2024-01-01, 30d
11
+ Task2 :after a1, 20d
12
+ Task3 :milestone, m1, 2024-02-15, 0d
13
+ Task4 :until a1
14
+
15
+ Rendered as section-grouped rows with a horizontal time-span bar per task,
16
+ scaled to the overall date range — the gantt analogue of a timeline, per the
17
+ ratified plan in ``mermaid-inline-rendering.md``. Milestones render as a
18
+ single point marker instead of a span.
19
+
20
+ Never-crash contract: a diagram that uses a construct we don't implement
21
+ (an invalid ``dateFormat``, a numeric overflow, an ``until`` reference to an
22
+ unknown task id, an unsupported ``excludes``/``includes`` form, ...)
23
+ degrades the *entire* diagram to raw source rather than guessing — see
24
+ ``_Unsupported`` below.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from datetime import date, datetime, timedelta
31
+
32
+ from termrender.style import visual_len, visual_ljust
33
+
34
+ _HEADER_RE = re.compile(r"^\s*gantt\b", re.IGNORECASE)
35
+ _TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
36
+ _DATEFORMAT_RE = re.compile(r"^\s*dateFormat\s+(\S+)\s*$", re.IGNORECASE)
37
+ _SECTION_RE = re.compile(r"^\s*section\s+(.*\S)\s*$", re.IGNORECASE)
38
+ _EXCLUDES_RE = re.compile(r"^\s*excludes\s+(.*\S)\s*$", re.IGNORECASE)
39
+ _INCLUDES_RE = re.compile(r"^\s*includes\s+(.*\S)\s*$", re.IGNORECASE)
40
+ _SKIP_RE = re.compile(
41
+ r"^\s*(?:axisFormat|todayMarker|weekend|tickInterval)\b",
42
+ re.IGNORECASE,
43
+ )
44
+ _COMMENT_RE = re.compile(r"%%.*$")
45
+ _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
46
+ _STATUS_TOKENS = {"done", "active", "crit", "milestone", "vert"}
47
+ _DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*(d|w|h|m|s)$", re.IGNORECASE)
48
+ _ID_RE = re.compile(r"^[A-Za-z_][\w-]*$")
49
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
50
+
51
+ # Mermaid dateFormat tokens, longest-first so e.g. "YYYY" isn't partially
52
+ # consumed by a "YY" replacement first.
53
+ _DATE_TOKEN_MAP = [
54
+ ("YYYY", "%Y"),
55
+ ("YY", "%y"),
56
+ ("MM", "%m"),
57
+ ("DD", "%d"),
58
+ ("HH", "%H"),
59
+ ("mm", "%M"),
60
+ ("ss", "%S"),
61
+ ]
62
+
63
+
64
+ class _Unsupported(Exception):
65
+ """Internal signal: the diagram uses a construct we can't safely
66
+ render (invalid dateFormat, numeric overflow, an unresolvable
67
+ ``until`` reference, an unimplemented ``excludes``/``includes`` form,
68
+ ...). Caught by ``parse_gantt``, which degrades the whole diagram to
69
+ raw source rather than rendering a guess.
70
+ """
71
+
72
+
73
+ def _strptime_format(date_format: str) -> str:
74
+ """Translate a mermaid ``dateFormat`` token string to a strptime pattern."""
75
+ out = date_format
76
+ for token, code in _DATE_TOKEN_MAP:
77
+ out = out.replace(token, code)
78
+ return out
79
+
80
+
81
+ def _valid_strptime_format(fmt: str) -> bool:
82
+ """Best-effort validation that ``fmt`` is usable as a strptime pattern.
83
+
84
+ A malformed ``dateFormat`` such as ``YYYY-YYYY`` translates to
85
+ ``%Y-%Y``, which has a duplicate named capture group; that only
86
+ surfaces as ``re.error`` when strptime actually compiles the pattern
87
+ to parse a value, not at translation time. Round-tripping a sample
88
+ date through ``strftime``/``strptime`` exercises that compile step.
89
+ """
90
+ sample = datetime(2024, 3, 4, 5, 6, 7)
91
+ try:
92
+ datetime.strptime(sample.strftime(fmt), fmt)
93
+ except (ValueError, re.error):
94
+ return False
95
+ return True
96
+
97
+
98
+ def _parse_date(token: str, fmt: str) -> datetime | None:
99
+ try:
100
+ return datetime.strptime(token, fmt)
101
+ except (ValueError, re.error):
102
+ return None
103
+
104
+
105
+ def _parse_excludes_tokens(spec: str) -> tuple[bool, set[date]]:
106
+ """Parse the token list of an ``excludes`` directive.
107
+
108
+ Supports the common cases: the ``weekends`` keyword and explicit
109
+ ``YYYY-MM-DD`` dates. Any other form (e.g. day names like ``friday``)
110
+ isn't implemented, so it raises ``_Unsupported`` to degrade the whole
111
+ diagram rather than silently ignoring a calendar rule that changes the
112
+ schedule.
113
+ """
114
+ weekends = False
115
+ dates: set[date] = set()
116
+ for tok in re.split(r"[,\s]+", spec.strip()):
117
+ if not tok:
118
+ continue
119
+ if tok.lower() == "weekends":
120
+ weekends = True
121
+ continue
122
+ if _ISO_DATE_RE.match(tok):
123
+ dt = _parse_date(tok, "%Y-%m-%d")
124
+ if dt is None:
125
+ raise _Unsupported(f"unparseable excludes date {tok!r}")
126
+ dates.add(dt.date())
127
+ continue
128
+ raise _Unsupported(f"unsupported excludes token {tok!r}")
129
+ return weekends, dates
130
+
131
+
132
+ def _parse_includes_tokens(spec: str) -> set[date]:
133
+ """Parse the token list of an ``includes`` directive.
134
+
135
+ Only explicit ``YYYY-MM-DD`` dates are implemented (re-including a
136
+ specific day that ``excludes`` would otherwise skip). ``weekends``
137
+ isn't a supported ``includes`` token — there's no "re-include every
138
+ weekend" semantics implemented — so it raises ``_Unsupported`` to
139
+ degrade the whole diagram rather than silently no-op-ing a directive
140
+ that looks like it should cancel an ``excludes weekends`` rule.
141
+ """
142
+ dates: set[date] = set()
143
+ for tok in re.split(r"[,\s]+", spec.strip()):
144
+ if not tok:
145
+ continue
146
+ if _ISO_DATE_RE.match(tok):
147
+ dt = _parse_date(tok, "%Y-%m-%d")
148
+ if dt is None:
149
+ raise _Unsupported(f"unparseable includes date {tok!r}")
150
+ dates.add(dt.date())
151
+ continue
152
+ raise _Unsupported(f"unsupported includes token {tok!r}")
153
+ return dates
154
+
155
+
156
+ def _is_excluded(
157
+ d: date, exclude_weekends: bool, exclude_dates: set[date], include_dates: set[date]
158
+ ) -> bool:
159
+ if d in include_dates:
160
+ return False
161
+ if exclude_weekends and d.weekday() >= 5:
162
+ return True
163
+ return d in exclude_dates
164
+
165
+
166
+ def _skip_excluded(
167
+ start: datetime,
168
+ exclude_weekends: bool,
169
+ exclude_dates: set[date],
170
+ include_dates: set[date],
171
+ ) -> datetime:
172
+ """If an auto-resolved ``start`` falls on an excluded day, advance to
173
+ the next working day."""
174
+ if not exclude_weekends and not exclude_dates:
175
+ return start
176
+ while _is_excluded(start.date(), exclude_weekends, exclude_dates, include_dates):
177
+ start = start + timedelta(days=1)
178
+ return start
179
+
180
+
181
+ def _advance_working_days(
182
+ start: datetime,
183
+ days: float,
184
+ exclude_weekends: bool,
185
+ exclude_dates: set[date],
186
+ include_dates: set[date],
187
+ ) -> datetime:
188
+ """Advance ``start`` by ``days`` working days, skipping excluded days
189
+ (which extend the span but don't count toward the duration).
190
+
191
+ Raises ``OverflowError`` (propagated to the caller as ``_Unsupported``)
192
+ for magnitudes ``datetime`` can't represent, both to never crash on
193
+ huge durations and to bound the day-by-day walk below.
194
+ """
195
+ start + timedelta(days=days) # bounds-check before the exclusion walk
196
+ if not exclude_weekends and not exclude_dates:
197
+ return start + timedelta(days=days)
198
+ whole = int(days)
199
+ frac = days - whole
200
+ cur = start
201
+ remaining = whole
202
+ while remaining > 0:
203
+ cur = cur + timedelta(days=1)
204
+ if _is_excluded(cur.date(), exclude_weekends, exclude_dates, include_dates):
205
+ continue
206
+ remaining -= 1
207
+ if frac:
208
+ cur = cur + timedelta(days=frac)
209
+ return cur
210
+
211
+
212
+ def parse_gantt(source: str) -> dict:
213
+ """Parse the core mermaid gantt grammar into a title + section/task tree.
214
+
215
+ Handles ``dateFormat``, ``title``, ``section``, ``excludes``
216
+ (``weekends`` and explicit dates), ``includes`` (explicit dates only),
217
+ ``%%`` comments, ``milestone`` tasks, ``until <taskId>``, and task
218
+ lines of the form ``name : [status,] [id,] [start-date|after id,]
219
+ duration-or-end``. Lines that don't match a recognized shape (a task
220
+ with no resolvable start anchor, an unrecognized status keyword, ...)
221
+ are skipped rather than raising — agents emit a wide variety of
222
+ gantt dialects and an unparseable line must degrade, not crash.
223
+
224
+ A construct we can't safely resolve (an invalid ``dateFormat``, a
225
+ numeric or date-arithmetic overflow, an ``until`` reference to an
226
+ unknown task id, or an ``excludes``/``includes`` form beyond
227
+ ``weekends``/explicit dates) degrades the *whole* diagram: this
228
+ returns ``{"title": None, "sections": []}``, which the renderer
229
+ falls back to raw source for.
230
+
231
+ Returns ``{"title": str | None, "sections": [{"name": str | None,
232
+ "tasks": [{"label": str, "start": datetime, "end": datetime,
233
+ "milestone": bool}]}]}``. Sections with no successfully parsed tasks
234
+ are dropped.
235
+ """
236
+ try:
237
+ return _parse_gantt(source)
238
+ except _Unsupported:
239
+ return {"title": None, "sections": []}
240
+
241
+
242
+ def _parse_gantt(source: str) -> dict:
243
+ fmt = "%Y-%m-%d"
244
+ title: str | None = None
245
+ sections: list[dict] = [{"name": None, "tasks": []}]
246
+ task_ends: dict[str, datetime] = {}
247
+ task_starts: dict[str, datetime] = {}
248
+ last_end: datetime | None = None
249
+ exclude_weekends = False
250
+ exclude_dates: set[date] = set()
251
+ include_dates: set[date] = set()
252
+
253
+ for raw_line in source.splitlines():
254
+ line = _COMMENT_RE.sub("", raw_line)
255
+ if not line.strip() or _HEADER_RE.match(line) or _SKIP_RE.match(line):
256
+ continue
257
+
258
+ m = _TITLE_RE.match(line)
259
+ if m:
260
+ title = m.group(1)
261
+ continue
262
+
263
+ m = _DATEFORMAT_RE.match(line)
264
+ if m:
265
+ candidate = _strptime_format(m.group(1))
266
+ if not _valid_strptime_format(candidate):
267
+ raise _Unsupported(f"invalid dateFormat {m.group(1)!r}")
268
+ fmt = candidate
269
+ continue
270
+
271
+ m = _SECTION_RE.match(line)
272
+ if m:
273
+ sections.append({"name": m.group(1), "tasks": []})
274
+ continue
275
+
276
+ m = _EXCLUDES_RE.match(line)
277
+ if m:
278
+ w, d = _parse_excludes_tokens(m.group(1))
279
+ exclude_weekends = exclude_weekends or w
280
+ exclude_dates |= d
281
+ continue
282
+
283
+ m = _INCLUDES_RE.match(line)
284
+ if m:
285
+ include_dates |= _parse_includes_tokens(m.group(1))
286
+ continue
287
+
288
+ if ":" not in line:
289
+ continue
290
+ label, _, spec = line.partition(":")
291
+ label = label.strip()
292
+ if not label:
293
+ continue
294
+
295
+ task_id: str | None = None
296
+ start: datetime | None = None
297
+ end: datetime | None = None
298
+ duration_amount: float | None = None
299
+ duration_unit: str | None = None
300
+ until_id: str | None = None
301
+ is_milestone = False
302
+ after_ids: list[str] = []
303
+
304
+ for raw_tok in spec.split(","):
305
+ tok = raw_tok.strip()
306
+ if not tok:
307
+ continue
308
+ low = tok.lower()
309
+ if low in _STATUS_TOKENS:
310
+ if low == "milestone":
311
+ is_milestone = True
312
+ continue
313
+ if low.startswith("after "):
314
+ after_ids.extend(tok[len("after "):].split())
315
+ continue
316
+ if low.startswith("until "):
317
+ until_id = tok[len("until "):].strip()
318
+ continue
319
+ dm = _DURATION_RE.match(tok)
320
+ if dm:
321
+ duration_amount, duration_unit = float(dm.group(1)), dm.group(2).lower()
322
+ continue
323
+ dt = _parse_date(tok, fmt)
324
+ if dt is not None:
325
+ if start is None:
326
+ start = dt
327
+ else:
328
+ end = dt
329
+ continue
330
+ if task_id is None and _ID_RE.match(tok):
331
+ task_id = tok
332
+ continue
333
+ # Unrecognized token (e.g. an unmapped status keyword): ignore
334
+ # and keep scanning the remaining tokens on this line.
335
+
336
+ if start is None:
337
+ if after_ids:
338
+ resolved = [task_ends[i] for i in after_ids if i in task_ends]
339
+ start = max(resolved) if resolved else last_end
340
+ else:
341
+ start = last_end
342
+ if start is not None:
343
+ try:
344
+ start = _skip_excluded(
345
+ start, exclude_weekends, exclude_dates, include_dates
346
+ )
347
+ except OverflowError:
348
+ raise _Unsupported("date overflow skipping excluded days") from None
349
+
350
+ if start is None:
351
+ # No date, no "after" dependency, no prior task to anchor to —
352
+ # this task can't be placed on the timeline. Skip it.
353
+ continue
354
+
355
+ if is_milestone:
356
+ end = start
357
+ elif end is None:
358
+ if until_id is not None:
359
+ if until_id not in task_starts:
360
+ raise _Unsupported(f"until references unknown task {until_id!r}")
361
+ end = task_starts[until_id]
362
+ elif duration_amount is not None:
363
+ try:
364
+ if duration_unit in ("d", "w"):
365
+ days = duration_amount * (7 if duration_unit == "w" else 1)
366
+ end = _advance_working_days(
367
+ start, days, exclude_weekends, exclude_dates, include_dates
368
+ )
369
+ else:
370
+ end = start + timedelta(
371
+ seconds=duration_amount * _UNIT_SECONDS[duration_unit]
372
+ )
373
+ except OverflowError:
374
+ raise _Unsupported("duration overflow") from None
375
+ else:
376
+ try:
377
+ end = start + timedelta(days=1)
378
+ except OverflowError:
379
+ raise _Unsupported("date overflow computing default end") from None
380
+ if end < start:
381
+ end = start
382
+
383
+ sections[-1]["tasks"].append(
384
+ {"label": label, "start": start, "end": end, "milestone": is_milestone}
385
+ )
386
+ last_end = end
387
+ if task_id:
388
+ task_ends[task_id] = end
389
+ task_starts[task_id] = start
390
+
391
+ sections = [s for s in sections if s["tasks"]]
392
+ return {"title": title, "sections": sections}
393
+
394
+
395
+ def _draw_span(width: int, start_ratio: float, end_ratio: float) -> str:
396
+ """Draw a ``width``-wide track with a filled span from start to end ratio."""
397
+ if width <= 0:
398
+ return ""
399
+ start_col = max(0, min(int(round(start_ratio * width)), width))
400
+ end_col = max(start_col + 1, min(int(round(end_ratio * width)), width))
401
+ return "░" * start_col + "█" * (end_col - start_col) + "░" * (width - end_col)
402
+
403
+
404
+ def _draw_milestone(width: int, ratio: float) -> str:
405
+ """Draw a ``width``-wide track with a single point marker (a milestone
406
+ is an instant, not a span)."""
407
+ if width <= 0:
408
+ return ""
409
+ col = max(0, min(int(round(ratio * width)), width - 1))
410
+ return "░" * col + "◆" + "░" * (width - col - 1)
411
+
412
+
413
+ def _fmt_range(task: dict) -> str:
414
+ start, end = task["start"], task["end"]
415
+ if start.date() == end.date():
416
+ return start.strftime("%Y-%m-%d")
417
+ return f"{start:%Y-%m-%d}\u2192{end:%Y-%m-%d}"
418
+
419
+
420
+ def render(source: str, width: int) -> list[str]:
421
+ """Render mermaid gantt syntax as section-grouped rows with time-span bars.
422
+
423
+ Milestone tasks render as a single point marker rather than a span.
424
+ Diagrams with no parseable tasks (including diagrams degraded by
425
+ ``parse_gantt`` due to an unsupported construct) fall back to the raw
426
+ source text.
427
+ """
428
+ parsed = parse_gantt(source)
429
+ tasks = [t for s in parsed["sections"] for t in s["tasks"]]
430
+ if not tasks:
431
+ return source.splitlines() or [""]
432
+
433
+ min_start = min(t["start"] for t in tasks)
434
+ max_end = max(t["end"] for t in tasks)
435
+ span = (max_end - min_start).total_seconds() or 1.0
436
+
437
+ label_w = max(visual_len(t["label"]) for t in tasks)
438
+ date_w = max(visual_len(_fmt_range(t)) for t in tasks)
439
+ bar_w = max(width - label_w - date_w - 4, 5)
440
+
441
+ lines: list[str] = []
442
+ if parsed["title"]:
443
+ lines.append(visual_ljust(parsed["title"], width))
444
+
445
+ for section in parsed["sections"]:
446
+ if section["name"]:
447
+ lines.append(visual_ljust(section["name"], width))
448
+ for t in section["tasks"]:
449
+ start_ratio = (t["start"] - min_start).total_seconds() / span
450
+ if t["milestone"]:
451
+ bar = _draw_milestone(bar_w, start_ratio)
452
+ else:
453
+ end_ratio = (t["end"] - min_start).total_seconds() / span
454
+ bar = _draw_span(bar_w, start_ratio, end_ratio)
455
+ label = visual_ljust(t["label"], label_w)
456
+ date_str = _fmt_range(t).rjust(date_w)
457
+ lines.append(visual_ljust(f"{label} {bar} {date_str}", width))
458
+
459
+ return lines
@@ -0,0 +1,128 @@
1
+ """Native renderer for mermaid ``pie`` diagrams.
2
+
3
+ Mermaid pie grammar (subset)::
4
+
5
+ pie [showData]
6
+ title An Optional Title
7
+ "Label A" : 40
8
+ 'Label B' : 60
9
+
10
+ A text pie chart is worse than a labeled bar chart at the same information
11
+ density, so pie diagrams are re-expressed as a horizontal bar chart (see the
12
+ ratified plan in ``mermaid-inline-rendering.md``), reusing ``charts.py``'s
13
+ ``render_bar`` so the visual style matches the rest of termrender's charts.
14
+
15
+ Never-crash / never-lie contract: real Mermaid pie grammar only permits the
16
+ header, ``title``/``accTitle``/``accDescr``/``%%``-comment lines, and slice
17
+ declarations — everything else is a parse error. A line that doesn't match
18
+ one of those, or a negative slice value (Mermaid rejects negative pie
19
+ values), degrades the *whole* diagram to raw source rather than rendering a
20
+ plausible-but-wrong chart with the bad line silently dropped.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ import re
27
+
28
+ from termrender.blocks import Block, BlockType
29
+ from termrender.renderers.charts import render_bar
30
+
31
+ _TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
32
+ _ACC_RE = re.compile(r"^\s*acc(?:Title|Descr)\b", re.IGNORECASE)
33
+ _COMMENT_RE = re.compile(r"^\s*%%")
34
+ _HEADER_RE = re.compile(r"^\s*pie\b(?:\s+showData\b)?\s*(.*)$", re.IGNORECASE)
35
+ # Group 1: double-quoted label body (escaped quotes allowed, unescaped below).
36
+ # Group 2: single-quoted label body.
37
+ _DATA_RE = re.compile(
38
+ r'^\s*(?:"((?:[^"\\]|\\.)*)"|\'([^\']*)\')\s*:\s*(-?\d+(?:\.\d+)?)\s*$'
39
+ )
40
+
41
+
42
+ def _unescape_double_quoted(label: str) -> str:
43
+ return label.replace('\\"', '"').replace("\\\\", "\\")
44
+
45
+
46
+ def parse_pie(source: str) -> tuple[str | None, list[dict]]:
47
+ """Parse mermaid pie syntax into ``(title, items)``.
48
+
49
+ ``items`` is a list of ``{"label": str, "value": float}`` dicts, in
50
+ source order. Blank lines, ``%%`` comments, and ``accTitle``/
51
+ ``accDescr`` lines are legitimate Mermaid ignorables and are skipped.
52
+ Any other line that isn't the header, a ``title``, or a
53
+ ``"label" : value`` slice (double- or single-quoted, with escaped
54
+ quotes supported in double-quoted labels) is invalid Mermaid grammar;
55
+ a negative slice value is likewise invalid (Mermaid rejects negative
56
+ pie values). Either case discards everything parsed so far and
57
+ returns ``(None, [])`` so the caller falls back to raw source instead
58
+ of rendering a partial, misleading chart.
59
+ """
60
+ title: str | None = None
61
+ items: list[dict] = []
62
+ for line in source.splitlines():
63
+ if not line.strip():
64
+ continue
65
+ if _COMMENT_RE.match(line) or _ACC_RE.match(line):
66
+ continue
67
+ m = _HEADER_RE.match(line)
68
+ if m:
69
+ # Mermaid allows the title inline on the header line:
70
+ # ``pie title X`` / ``pie showData title X``. Anything else
71
+ # trailing the header is invalid grammar.
72
+ tail = m.group(1)
73
+ if tail:
74
+ tm = _TITLE_RE.match(tail)
75
+ if not tm:
76
+ return None, []
77
+ title = tm.group(1)
78
+ continue
79
+ m = _TITLE_RE.match(line)
80
+ if m:
81
+ title = m.group(1)
82
+ continue
83
+ m = _DATA_RE.match(line)
84
+ if m:
85
+ if m.group(1) is not None:
86
+ label = _unescape_double_quoted(m.group(1))
87
+ else:
88
+ label = m.group(2)
89
+ value = float(m.group(3))
90
+ if value < 0 or not math.isfinite(value):
91
+ return None, []
92
+ items.append({"label": label, "value": value})
93
+ continue
94
+ # Invalid grammar: not header, title, acc line, comment, or data.
95
+ return None, []
96
+ return title, items
97
+
98
+
99
+ def render(source: str, width: int) -> list[str]:
100
+ """Render mermaid pie syntax as a labeled horizontal bar chart.
101
+
102
+ Each bar's value is annotated with its percentage share of the total.
103
+ Diagrams with no parseable data lines (including diagrams degraded by
104
+ ``parse_pie`` due to invalid grammar or a negative value) degrade to
105
+ the raw source text.
106
+ """
107
+ title, items = parse_pie(source)
108
+ if not items:
109
+ return source.splitlines() or [""]
110
+
111
+ total = sum(it["value"] for it in items) or 1.0
112
+ if not math.isfinite(total):
113
+ return source.splitlines() or [""]
114
+ bar_items = [
115
+ {
116
+ "label": it["label"],
117
+ "value": it["value"],
118
+ "unit": f" ({it['value'] / total * 100:.1f}%)",
119
+ }
120
+ for it in items
121
+ ]
122
+
123
+ block = Block(
124
+ type=BlockType.BAR,
125
+ attrs={"items": bar_items, "title": title, "color": "cyan"},
126
+ width=width,
127
+ )
128
+ return render_bar(block, color=False)