munchboka-edutools 0.1.13__py3-none-any.whl

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.

Potentially problematic release.


This version of munchboka-edutools might be problematic. Click here for more details.

Files changed (149) hide show
  1. munchboka_edutools/__init__.py +184 -0
  2. munchboka_edutools/_plotmath_shim.py +126 -0
  3. munchboka_edutools/_version.py +2 -0
  4. munchboka_edutools/directives/__init__.py +1 -0
  5. munchboka_edutools/directives/admonitions.py +389 -0
  6. munchboka_edutools/directives/cas_popup.py +272 -0
  7. munchboka_edutools/directives/clear.py +103 -0
  8. munchboka_edutools/directives/dialogue.py +137 -0
  9. munchboka_edutools/directives/escape_room.py +296 -0
  10. munchboka_edutools/directives/factor_tree.py +549 -0
  11. munchboka_edutools/directives/ggb.py +209 -0
  12. munchboka_edutools/directives/ggb_icon.py +105 -0
  13. munchboka_edutools/directives/ggb_popup.py +165 -0
  14. munchboka_edutools/directives/horner.py +324 -0
  15. munchboka_edutools/directives/interactive_code.py +75 -0
  16. munchboka_edutools/directives/jeopardy.py +252 -0
  17. munchboka_edutools/directives/multi_plot.py +1126 -0
  18. munchboka_edutools/directives/pair_puzzle.py +191 -0
  19. munchboka_edutools/directives/parsons.py +109 -0
  20. munchboka_edutools/directives/plot.py +3105 -0
  21. munchboka_edutools/directives/poly_icon.py +111 -0
  22. munchboka_edutools/directives/polydiv.py +344 -0
  23. munchboka_edutools/directives/popup.py +245 -0
  24. munchboka_edutools/directives/quiz.py +291 -0
  25. munchboka_edutools/directives/signchart.py +516 -0
  26. munchboka_edutools/directives/timed_quiz.py +436 -0
  27. munchboka_edutools/directives/turtle.py +157 -0
  28. munchboka_edutools/static/css/admonitions.css +2012 -0
  29. munchboka_edutools/static/css/cas_popup.css +242 -0
  30. munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
  31. munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
  32. munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
  33. munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
  34. munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
  35. munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
  36. munchboka_edutools/static/css/dialogue.css +92 -0
  37. munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
  38. munchboka_edutools/static/css/figures.css +274 -0
  39. munchboka_edutools/static/css/general_style.css +74 -0
  40. munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
  41. munchboka_edutools/static/css/github-dark.css +112 -0
  42. munchboka_edutools/static/css/github-light.css +120 -0
  43. munchboka_edutools/static/css/interactive_code/style.css +575 -0
  44. munchboka_edutools/static/css/interactive_code.css +582 -0
  45. munchboka_edutools/static/css/jeopardy.css +529 -0
  46. munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
  47. munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
  48. munchboka_edutools/static/css/popup.css +115 -0
  49. munchboka_edutools/static/css/quiz.css +312 -0
  50. munchboka_edutools/static/css/timedQuiz.css +375 -0
  51. munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
  52. munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
  53. munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
  54. munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
  55. munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
  56. munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
  57. munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
  58. munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
  59. munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
  60. munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
  61. munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
  62. munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
  63. munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
  64. munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
  65. munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
  66. munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
  67. munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
  68. munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
  69. munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
  70. munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
  71. munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
  72. munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
  73. munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
  74. munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
  75. munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
  76. munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
  77. munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
  78. munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
  79. munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
  80. munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
  81. munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
  82. munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
  83. munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
  84. munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
  85. munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
  86. munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
  87. munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
  88. munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
  89. munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
  90. munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
  91. munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
  92. munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
  93. munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
  94. munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
  95. munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
  96. munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
  97. munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
  98. munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
  99. munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
  100. munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
  101. munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
  102. munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
  103. munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
  104. munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
  105. munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
  106. munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
  107. munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
  108. munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
  109. munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
  110. munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
  111. munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
  112. munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
  113. munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
  114. munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
  115. munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
  116. munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
  117. munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
  118. munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
  119. munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
  120. munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
  121. munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
  122. munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
  123. munchboka_edutools/static/icons/stickers/edit.svg +1 -0
  124. munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
  125. munchboka_edutools/static/js/casThemeManager.js +99 -0
  126. munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
  127. munchboka_edutools/static/js/geogebra-setup.js +6 -0
  128. munchboka_edutools/static/js/highlight-init.js +6 -0
  129. munchboka_edutools/static/js/interactiveCode/codeEditor.js +632 -0
  130. munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +348 -0
  131. munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
  132. munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
  133. munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
  134. munchboka_edutools/static/js/jeopardy.js +523 -0
  135. munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
  136. munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
  137. munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
  138. munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
  139. munchboka_edutools/static/js/popup.js +85 -0
  140. munchboka_edutools/static/js/quiz.js +422 -0
  141. munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
  142. munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
  143. munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
  144. munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
  145. munchboka_edutools/static/js/utils.js +3 -0
  146. munchboka_edutools-0.1.13.dist-info/METADATA +108 -0
  147. munchboka_edutools-0.1.13.dist-info/RECORD +149 -0
  148. munchboka_edutools-0.1.13.dist-info/WHEEL +4 -0
  149. munchboka_edutools-0.1.13.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3105 @@
1
+ r"""Plot directive — full reference (supports rich SymPy expressions)
2
+
3
+ This directive builds flexible, textbook‑style mathematical plots using a
4
+ compact YAML‑like front matter plus repeated keys for geometric *primitives*.
5
+ It powers interactive / pedagogical figures while remaining deliberately
6
+ fault‑tolerant: malformed items are skipped instead of aborting the build.
7
+
8
+ NEW / EXTENDED FEATURES (summary)
9
+ ---------------------------------
10
+ * Unified expression evaluator: almost every numeric field now accepts SymPy
11
+ expressions (``pi``, ``sqrt(2)``, ``exp(1/3)``, arithmetic, trig, etc.).
12
+ * Function domains & exclusions accept expressions: ``(0, 2*sqrt(5))`` or
13
+ ``( -pi, pi ) \ { -pi/2, pi/2 }``.
14
+ * Points, polygons, fill‑polygons, line‑segments, vectors, angle‑arcs,
15
+ circles, ellipses, and parametric curves all evaluate coordinates via SymPy.
16
+ * New primitives: ``circle``, ``ellipse``, ``curve`` (parametric ``x(t), y(t)``).
17
+ * Style / color tokens order‑independent for line‑segment, circle, ellipse,
18
+ curve, angle‑arc (after required numeric parts).
19
+ * Palette mapping: colors first resolved through ``plotmath.COLORS`` then
20
+ fall back to the literal token then a sensible default.
21
+
22
+ Quick start (MyST)
23
+ ------------------
24
+ :::{plot}
25
+ function: sin(x)/x, f(x), (-6*pi, 6*pi) \ {0}
26
+ curve: cos(t), sin(2*t), (0, 2*pi), dashed, orange
27
+ circle: (0,0), 2*pi/6, dotted, green
28
+ ellipse: (1, -1), 3, sqrt(5), red
29
+ point: (pi, f(pi))
30
+ line-segment: (0,0), (2*sqrt(2), 2), dashed, purple
31
+ vector: 0, 0, 2*cos(pi/6), 2*sin(pi/6), teal
32
+ angle-arc: (0,0), 2.5, 0, 60, dashed
33
+ annotate: (pi, f(pi)), (pi, 0), "Maximum?", 0.25
34
+ xmin: -10
35
+ xmax: 10
36
+ ymin: -5
37
+ ymax: 5
38
+ grid: on
39
+ ticks: true
40
+ fontsize: 22
41
+ width: 100%
42
+ xlabel: $x$
43
+ ylabel: $y$
44
+ :::
45
+
46
+ MyST *or* classic reST (``.. plot::``) forms are accepted. All examples below
47
+ use MyST for brevity.
48
+
49
+ Front matter
50
+ ------------
51
+ * Provide a fenced block introduced by ``:::{plot}`` (preferred) or use an
52
+ unfenced reST directive. Within the block, write ``key: value`` lines.
53
+ * A blank line ends the front matter; remaining lines become the caption.
54
+ * Repeated keys (multi‑valued): ``function``, ``point``, ``annotate``, ``text``,
55
+ ``vline``, ``hline``, ``line``, ``line-segment``, ``polygon``, ``fill-polygon``,
56
+ ``bar``, ``axis``, ``vector``, ``angle-arc``, ``circle``, ``ellipse``, ``curve``.
57
+
58
+ Global figure & layout options
59
+ ------------------------------
60
+ width CSS width (``100%`` or pixels).
61
+ figsize ``(w, h)`` in inches; applied at the end.
62
+ align ``left|center|right``.
63
+ class Extra CSS classes.
64
+ name Stable output filename / anchor.
65
+ alt Alt text (accessibility).
66
+ nocache Force regeneration (ignore cache).
67
+ debug Keep raw SVG size & emit sidecar PDF if possible.
68
+ usetex ``true|false`` force LaTeX text rendering via matplotlib (default ``true``; can be set globally via ``plot_default_usetex`` in ``conf.py``).
69
+ fontsize Base font size (default 20).
70
+ lw Default line width for plotted curves (default 2.5).
71
+ alpha Global alpha for function / curve lines (optional).
72
+ xmin,xmax,ymin,ymax Axis bounds (defaults ±6).
73
+ xstep,ystep Tick spacing (default 1).
74
+ ticks ``true|false`` master toggle for ticks/labels.
75
+ xticks|yticks ``off`` to remove one axis’s ticks.
76
+ grid ``true|false`` (independent of ``ticks``).
77
+ xlabel,ylabel Axis labels; can add a pad: ``xlabel: $t$, 8``.
78
+ axis Repeated key for special modes: ``off``, ``equal`` (may combine).
79
+
80
+ Expression support
81
+ ------------------
82
+ All numeric coordinates (centers, radii, interval endpoints, slopes, etc.) may
83
+ be SymPy expressions. Allowed names include: ``pi``, ``E``, ``sqrt``, ``exp``,
84
+ ``log``, ``sin``, ``cos``, ``tan``, ``asin``, ``acos``, ``atan``, ``Abs`` plus
85
+ basic arithmetic. A small, safe namespace is used; arbitrary Python execution
86
+ is not performed. If evaluation fails the item is skipped silently.
87
+
88
+ Functions
89
+ ---------
90
+ Forms:
91
+ 1. ``function: expr`` — variable is ``x``.
92
+ 2. ``function: expr, label``
93
+ 3. ``function: expr (a,b)`` — domain restriction (open interval).
94
+ 4. ``function: expr (a,b) \ {x1, x2}`` — exclusions inside domain.
95
+ 5. List / tuple literal mixing expression, label, domain, exclusion set in
96
+ any order: ``function: [expr, "f(x)", (a,b), {x1, x2}]``.
97
+
98
+ Notes:
99
+ * Discontinuities and exclusions are split to avoid vertical strokes.
100
+ * Single‑letter labels are preserved (``g`` no longer coerced into a color).
101
+ * Labels auto‑wrapped for math; don’t include surrounding ``$``.
102
+
103
+ Points
104
+ ------
105
+ ``point: (x, y)`` where each coordinate can be an expression or a *function
106
+ label call* of the form ``label(number)`` (e.g. ``point: (2, f(2))``).
107
+
108
+ Vertical / horizontal lines
109
+ ---------------------------
110
+ ``vline: x[, ymin, ymax][, linestyle][, color]``
111
+ ``hline: y[, x0, x1][, linestyle][, color]``
112
+ Linestyles: ``solid|dotted|dashed|dashdot`` (order with color is free). Omitted
113
+ range endpoints default to current axis min/max.
114
+
115
+ General line and segments
116
+ -------------------------
117
+ ``line: a, (x0, y0)[, linestyle][, color]`` — draws ``y = a*(x - x0) + y0``.
118
+ ``line: a, b[, linestyle][, color]`` — draws ``y = a*x + b``.
119
+ ``line-segment: (x1, y1), (x2, y2)[, linestyle][, color]`` — finite segment.
120
+
121
+ Polygons
122
+ --------
123
+ ``polygon: (x1, y1), (x2, y2), ...[, show_vertices]`` — edges.
124
+ ``fill-polygon: (..)[, color][, alpha]`` — filled interior; first non‑numeric
125
+ extra = color, first numeric extra = alpha (default 0.1).
126
+ Coordinates accept expressions and function label calls.
127
+
128
+ Bars
129
+ ----
130
+ ``bar: (x, y), length, orientation`` where orientation is one of
131
+ ``h|hor|horiz|horizontal`` or ``v|vert|vertical``.
132
+
133
+ Vectors
134
+ -------
135
+ ``vector: x, y, dx, dy[, color]`` — all four numeric fields accept expressions.
136
+ Color mapped through palette then fallback to literal then black.
137
+
138
+ Angle arcs
139
+ ----------
140
+ ``angle-arc: (cx, cy), radius, start_deg, end_deg[, linestyle][, color]``.
141
+ Angles in *degrees* (mathematical CCW convention). All numeric parts allow
142
+ expressions. Optional style/color order‑independent after the first three
143
+ expressions.
144
+
145
+ Circles & ellipses
146
+ ------------------
147
+ Circle: ``circle: (cx, cy), r[, linestyle][, color]`` (r > 0).
148
+ Ellipse: ``ellipse: (cx, cy), a, b[, linestyle][, color]`` (a,b > 0).
149
+ Both sample 1024 points; style/color optional and order‑independent.
150
+
151
+ Parametric curves
152
+ -----------------
153
+ ``curve: x_expr, y_expr, (t0, t1)[, linestyle][, color]`` — samples 1024 points
154
+ with ``t`` symbol. Interval endpoints may be expressions (auto‑swapped if
155
+ reversed). Style/color optional.
156
+
157
+ Annotations & text
158
+ ------------------
159
+ ``annotate: (xytext), (xy), "text"[, arc]`` — arrow annotation; coordinates &
160
+ arc curvature can be expressions.
161
+ ``text: [x, y, string[, pos][, bbox]]`` — position tokens: ``top-left``,
162
+ ``center-center``, etc.; *long* variants shift further (e.g. ``longtop-left``).
163
+
164
+ Axis overrides: off / equal
165
+ ---------------------------
166
+ ``axis: off`` hides frame & ticks (manual artists still drawn).
167
+ ``axis: equal`` enforces 1:1 aspect (may combine with ``off``). Additional
168
+ ``axis: tight`` etc. still applied in visible‑axis mode.
169
+
170
+ Color & linestyle resolution
171
+ ----------------------------
172
+ 1. Try ``plotmath.COLORS[name]``.
173
+ 2. Fallback to the literal (Matplotlib named or hex) token.
174
+ 3. Fallback default (e.g. black, red, blue depending on primitive).
175
+ Single‑letter Matplotlib shorthands are *disabled for function labels* to
176
+ avoid ambiguity.
177
+
178
+ Safety & robustness
179
+ -------------------
180
+ * Expression evaluation uses SymPy in a restricted namespace (no exec / eval).
181
+ * Any parsing failure for an individual primitive silently skips that item.
182
+ * Cached SVGs stored under ``_static/plot/`` keyed by a content hash unless a
183
+ ``name:`` override is supplied.
184
+
185
+ Caption
186
+ -------
187
+ Lines after a blank line (or after the front matter fence) become the caption.
188
+
189
+ This docstring is intentionally exhaustive; HOWTOWRITE.md contains user‑facing
190
+ Norwegian examples.
191
+ """
192
+
193
+ from __future__ import annotations
194
+
195
+ import ast
196
+ import hashlib
197
+ import os
198
+ import re
199
+ import shutil
200
+ import uuid
201
+ from typing import Any, Callable, Dict, List, Tuple
202
+
203
+ from docutils import nodes
204
+ from docutils.parsers.rst import directives
205
+ from sphinx.util.docutils import SphinxDirective
206
+
207
+
208
+ # ------------------------------------
209
+ # Utilities
210
+ # ------------------------------------
211
+
212
+
213
+ def _hash_key(*parts) -> str:
214
+ h = hashlib.sha1()
215
+ for p in parts:
216
+ if p is None:
217
+ p = "__NONE__"
218
+ h.update(str(p).encode("utf-8"))
219
+ h.update(b"||")
220
+ return h.hexdigest()[:12]
221
+
222
+
223
+ def _compile_function(expr: str) -> Callable:
224
+ import sympy, numpy as np
225
+
226
+ expr = expr.strip()
227
+ x = sympy.symbols("x")
228
+ try:
229
+ sym = sympy.sympify(expr)
230
+ except Exception as e:
231
+ raise ValueError(f"Ugyldig funksjonsuttrykk '{expr}': {e}")
232
+ # If the expression does not depend on x, treat it as a constant function
233
+ if not sym.free_symbols or sym.free_symbols.isdisjoint({x}):
234
+ const_val = float(sym.evalf())
235
+
236
+ def f(arr):
237
+ a = np.asarray(arr, dtype=float)
238
+ return np.full_like(a, fill_value=const_val, dtype=float)
239
+
240
+ _ = f([0.0, 1.0])
241
+ return f
242
+
243
+ fn_np = sympy.lambdify(x, sym, modules=["numpy"])
244
+
245
+ def f(arr):
246
+ return fn_np(np.asarray(arr, dtype=float))
247
+
248
+ _ = f([0.0, 1.0])
249
+ return f
250
+
251
+
252
+ def _parse_bool(val, default: bool | None = None) -> bool | None:
253
+ if val is None:
254
+ return default
255
+ if isinstance(val, bool):
256
+ return val
257
+ s = str(val).strip().lower()
258
+ if s == "":
259
+ return True
260
+ if s in {"true", "yes", "on", "1"}:
261
+ return True
262
+ if s in {"false", "no", "off", "0"}:
263
+ return False
264
+ return default
265
+
266
+
267
+ def _strip_root_svg_size(svg_text: str) -> str:
268
+ def repl(m):
269
+ tag = m.group(0)
270
+ tag = re.sub(r'\swidth="[^"]+"', "", tag)
271
+ tag = re.sub(r'\sheight="[^"]+"', "", tag)
272
+ return tag
273
+
274
+ return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
275
+
276
+
277
+ def _rewrite_ids(txt: str, prefix: str) -> str:
278
+ ids = re.findall(r'\bid="([^"]+)"', txt)
279
+ if not ids:
280
+ return txt
281
+ skip_prefixes = (
282
+ "DejaVu",
283
+ "CM",
284
+ "STIX",
285
+ "Nimbus",
286
+ "Bitstream",
287
+ "Arial",
288
+ "Times",
289
+ "Helvetica",
290
+ )
291
+ mapping = {}
292
+ for i in ids:
293
+ if i.startswith(skip_prefixes):
294
+ continue
295
+ mapping[i] = f"{prefix}{i}"
296
+ if not mapping:
297
+ return txt
298
+
299
+ def repl_id(m: re.Match) -> str:
300
+ old = m.group(1)
301
+ new = mapping.get(old, old)
302
+ return f'id="{new}"'
303
+
304
+ txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
305
+
306
+ def repl_url(m: re.Match) -> str:
307
+ old = m.group(1).strip()
308
+ new = mapping.get(old, old)
309
+ return f"url(#{new})"
310
+
311
+ txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
312
+
313
+ def repl_href(m: re.Match) -> str:
314
+ attr = m.group(1)
315
+ quote = m.group(2)
316
+ old = m.group(3).strip()
317
+ new = mapping.get(old, old)
318
+ return f"{attr}={quote}#{new}{quote}"
319
+
320
+ txt = re.sub(r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2', repl_href, txt)
321
+ return txt
322
+
323
+
324
+ def _safe_literal(val: str):
325
+ try:
326
+ return ast.literal_eval(val)
327
+ except Exception:
328
+ return None
329
+
330
+
331
+ def _split_list(val: str) -> List[str]:
332
+ s = str(val or "").strip()
333
+ if not s:
334
+ return []
335
+ if s.startswith("[") and s.endswith("]"):
336
+ s = s[1:-1]
337
+ s = s.replace(";", ",")
338
+ return [p.strip() for p in s.split(",") if p.strip()]
339
+
340
+
341
+ def _parse_text_positioning(pos: str) -> Tuple[str, str]:
342
+ """Map positioning string to (va, ha). Default is (top, left).
343
+
344
+ Accepted values (case-insensitive, hyphen or underscore allowed):
345
+ - top-left, top-right, bottom-left, bottom-right,
346
+ - top-center, bottom-center, center-left, center-right
347
+ """
348
+ if not isinstance(pos, str):
349
+ return ("top", "left")
350
+ key = pos.strip().lower().replace("_", "-")
351
+
352
+ # Map onto the opposites to make intuitive sense.
353
+ # Matplotlib expects the position to refer to where the object is relative to the text.
354
+ # Thus "left" means the object is to the "left" of the text.
355
+ # Here "left" will mean "move the text to the left of the object"
356
+ mapping = {
357
+ "top-left": ("bottom", "right"),
358
+ "top-right": ("bottom", "left"),
359
+ "bottom-left": ("top", "right"),
360
+ "bottom-right": ("top", "left"),
361
+ "top-center": ("bottom", "center"),
362
+ "bottom-center": ("top", "center"),
363
+ "center-left": ("center", "right"),
364
+ "center-right": ("center", "left"),
365
+ # Longer distance from point
366
+ "longtop-left": ("longbottom", "left"),
367
+ "longtop-longleft": ("longbottom", "longright"),
368
+ "longbottom-right": ("longtop", "right"),
369
+ "longbottom-left": ("longtop", "left"),
370
+ "longtop-center": ("longbottom", "center"),
371
+ "longbottom-center": ("longtop", "center"),
372
+ "longtop-longright": ("longbottom", "longleft"),
373
+ "longbottom-longright": ("longtop", "longleft"),
374
+ "longtop-longleft": ("longbottom", "longright"),
375
+ "longbottom-longleft": ("longtop", "longright"),
376
+ "top-longleft": ("bottom", "longright"),
377
+ "top-longright": ("bottom", "longleft"),
378
+ "bottom-longleft": ("top", "longright"),
379
+ "bottom-longright": ("top", "longleft"),
380
+ "center-longleft": ("center", "longright"),
381
+ "center-longright": ("center", "longleft"),
382
+ "center-center": ("center", "center"),
383
+ }
384
+ return mapping.get(key, ("top", "left"))
385
+
386
+
387
+ class PlotDirective(SphinxDirective):
388
+ has_content = True
389
+ required_arguments = 0
390
+ option_spec = {
391
+ # presentation / misc
392
+ "width": directives.length_or_percentage_or_unitless,
393
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
394
+ "class": directives.class_option,
395
+ "name": directives.unchanged,
396
+ "nocache": directives.flag,
397
+ "debug": directives.flag,
398
+ "alt": directives.unchanged,
399
+ # text rendering
400
+ "usetex": directives.unchanged,
401
+ # axes options (optional in YAML too)
402
+ "xmin": directives.unchanged,
403
+ "xmax": directives.unchanged,
404
+ "ymin": directives.unchanged,
405
+ "ymax": directives.unchanged,
406
+ "xstep": directives.unchanged,
407
+ "ystep": directives.unchanged,
408
+ "fontsize": directives.unchanged,
409
+ "ticks": directives.unchanged,
410
+ "grid": directives.unchanged,
411
+ "xticks": directives.unchanged,
412
+ "yticks": directives.unchanged,
413
+ "lw": directives.unchanged,
414
+ "alpha": directives.unchanged,
415
+ "figsize": directives.unchanged,
416
+ # axis labels
417
+ "xlabel": directives.unchanged,
418
+ "ylabel": directives.unchanged,
419
+ }
420
+
421
+ def _parse_kv_block(self) -> Tuple[Dict[str, Any], Dict[str, List[str]], int]:
422
+ """Parse front matter supporting repeated keys for function/point/annotate.
423
+
424
+ Returns: (scalars, lists, caption_idx)
425
+ """
426
+ lines = list(self.content)
427
+ scalars: Dict[str, Any] = {}
428
+ lists: Dict[str, List[str]] = {
429
+ "function": [],
430
+ "point": [],
431
+ "annotate": [],
432
+ "text": [],
433
+ "vline": [],
434
+ "hline": [],
435
+ "line": [],
436
+ "tangent": [],
437
+ "polygon": [],
438
+ "axis": [],
439
+ "fill-polygon": [],
440
+ "bar": [],
441
+ "vector": [],
442
+ "line-segment": [],
443
+ "angle-arc": [],
444
+ "circle": [],
445
+ "ellipse": [],
446
+ "curve": [],
447
+ }
448
+ # YAML-like fenced front matter
449
+ if lines and lines[0].strip() == "---":
450
+ idx = 1
451
+ while idx < len(lines) and lines[idx].strip() != "---":
452
+ line = lines[idx].rstrip()
453
+ if not line.strip():
454
+ idx += 1
455
+ continue
456
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
457
+ if m:
458
+ key, value = m.group(1), m.group(2)
459
+ if key in lists:
460
+ lists[key].append(value)
461
+ else:
462
+ scalars[key] = value
463
+ idx += 1
464
+ # Skip closing fence if present
465
+ if idx < len(lines) and lines[idx].strip() == "---":
466
+ idx += 1
467
+ # Skip trailing blanks before caption
468
+ while idx < len(lines) and not lines[idx].strip():
469
+ idx += 1
470
+ return scalars, lists, idx
471
+
472
+ # Fallback: non-fenced lines until first non "key: value" or a blank separator
473
+ caption_start = 0
474
+ for i, line in enumerate(lines):
475
+ if not line.strip():
476
+ caption_start = i + 1
477
+ continue
478
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
479
+ if m:
480
+ key, value = m.group(1), m.group(2)
481
+ if key in lists:
482
+ lists[key].append(value)
483
+ else:
484
+ scalars[key] = value
485
+ caption_start = i + 1
486
+ else:
487
+ break
488
+ return scalars, lists, caption_start
489
+
490
+ def run(self):
491
+ env = self.state.document.settings.env
492
+ app = env.app
493
+ try:
494
+ import plotmath # type: ignore
495
+ import numpy as np # used for x sampling when plotting functions
496
+ except Exception:
497
+ # Fall back to bundled development shim so tests and docs can build without external plotmath
498
+ try:
499
+ from munchboka_edutools import _plotmath_shim as plotmath # type: ignore
500
+ import numpy as np # used for x sampling when plotting functions
501
+ except Exception as e:
502
+ err = nodes.error()
503
+ err += nodes.paragraph(text=f"Kunne ikke importere plotmath: {e}")
504
+ return [err]
505
+
506
+ scalars, lists, caption_idx = self._parse_kv_block()
507
+ merged: Dict[str, Any] = {**scalars, **self.options}
508
+
509
+ # debug print removed
510
+
511
+ def _f(name, default):
512
+ v = merged.get(name)
513
+ if v in (None, ""):
514
+ return default
515
+ try:
516
+ return float(v)
517
+ except Exception:
518
+ return default
519
+
520
+ xmin = _f("xmin", -6)
521
+ xmax = _f("xmax", 6)
522
+ ymin = _f("ymin", -6)
523
+ ymax = _f("ymax", 6)
524
+ xstep = _f("xstep", 1)
525
+ ystep = _f("ystep", 1)
526
+ fontsize = _f("fontsize", 20)
527
+ lw = _f("lw", 2.5)
528
+ alpha_raw = merged.get("alpha")
529
+ figsize_raw = merged.get("figsize")
530
+ try:
531
+ alpha = float(alpha_raw) if alpha_raw not in (None, "") else None
532
+ except Exception:
533
+ alpha = None
534
+
535
+ ticks_flag = _parse_bool(merged.get("ticks"), default=None)
536
+ grid_flag = _parse_bool(merged.get("grid"), default=None)
537
+
538
+ # Set defaults: if neither is specified, both default to True
539
+ if ticks_flag is None and grid_flag is None:
540
+ ticks_flag = True
541
+ grid_flag = True
542
+ else:
543
+ # If ticks not explicitly set, default to True (independent of grid setting)
544
+ if ticks_flag is None:
545
+ ticks_flag = True
546
+ else:
547
+ ticks_flag = bool(ticks_flag)
548
+
549
+ # If grid not explicitly set, default to True (independent of ticks setting)
550
+ if grid_flag is None:
551
+ grid_flag = True
552
+ else:
553
+ grid_flag = bool(grid_flag)
554
+
555
+ # Compile functions (may be zero or many) and parse optional labels, optional domain (xmin,xmax), exclusions and color
556
+ raw_fn_items = lists.get("function", [])
557
+ fn_exprs: List[str] = []
558
+ fn_labels_list: List[str] = []
559
+ fn_domains_list: List[Tuple[float, float] | None] = []
560
+ fn_exclusions_list: List[List[float]] = []
561
+ fn_colors_list: List[str] = []
562
+ functions: List[Callable] = []
563
+
564
+ def _parse_function_item(
565
+ s: str,
566
+ ) -> Tuple[
567
+ str,
568
+ str | None,
569
+ Tuple[float, float] | None,
570
+ List[float],
571
+ str | None,
572
+ ]:
573
+ s = str(s).strip()
574
+
575
+ # Heuristic to detect if a string token looks like a color
576
+ def _looks_like_color(tok: str) -> bool:
577
+ if not isinstance(tok, str):
578
+ return False
579
+ t = tok.strip()
580
+ if not t:
581
+ return False
582
+ # hex colors
583
+ if re.match(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", t):
584
+ return True
585
+ # IMPORTANT: single-letter matplotlib shorthands intentionally
586
+ # NOT treated as colors here so that users can name functions
587
+ # with letters like 'g' without it being consumed as green.
588
+ # Use full names (e.g. 'green') or color=green instead.
589
+ if t.lower().startswith("tab:"):
590
+ return True
591
+ if re.match(r"^C\d+$", t):
592
+ return True
593
+ # plotmath named colors
594
+ try:
595
+ import plotmath as _pm
596
+
597
+ if _pm.COLORS.get(t) is not None:
598
+ return True
599
+ except Exception:
600
+ pass
601
+ return False
602
+
603
+ # Try literal list/tuple like ("expr", "label") or ("expr", (xmin,xmax)) or ("expr", "label", (xmin,xmax)) in any order
604
+ lit = _safe_literal(s)
605
+ if isinstance(lit, (list, tuple)) and len(lit) >= 1:
606
+ expr = str(lit[0]).strip()
607
+ label: str | None = None
608
+ domain: Tuple[float, float] | None = None
609
+ excludes: List[float] = []
610
+ color: str | None = None
611
+ for item in list(lit[1:]):
612
+ # Detect domain tuple/list of two numbers
613
+ if domain is None and isinstance(item, (list, tuple)) and len(item) == 2:
614
+ try:
615
+ d0 = float(item[0])
616
+ d1 = float(item[1])
617
+ domain = (d0, d1)
618
+ continue
619
+ except Exception:
620
+ pass
621
+ # Detect exclusions as a collection of numbers (not a 2-tuple domain)
622
+ if isinstance(item, (set, list, tuple)) and not (
623
+ isinstance(item, (list, tuple)) and len(item) == 2
624
+ ):
625
+ for v in item:
626
+ try:
627
+ excludes.append(float(v))
628
+ except Exception:
629
+ pass
630
+ # Else string-like: support label/color, order-independent
631
+ if isinstance(item, str):
632
+ tok = item.strip()
633
+ if tok.lower().startswith("label="):
634
+ if label is None:
635
+ label = tok.split("=", 1)[1].strip()
636
+ continue
637
+ if tok.lower().startswith("color="):
638
+ if color is None:
639
+ color = tok.split("=", 1)[1].strip()
640
+ continue
641
+ if color is None and _looks_like_color(tok):
642
+ color = tok
643
+ elif label is None:
644
+ lab = tok
645
+ label = lab if lab else None
646
+ return expr, label, domain, excludes, color
647
+ # Fallback: attempt to locate a (domain) pattern (expr_a, expr_b) with fully balanced parentheses
648
+ # and optional exclusions of the form \{a,b,c}. Supports nested parentheses in each endpoint
649
+ # (e.g. (0, 2*sqrt(5)) or (pi/4, 3*pi/2 + sqrt(2))).
650
+ domain: Tuple[float, float] | None = None
651
+ excludes: List[float] = []
652
+ color: str | None = None
653
+
654
+ def _sym_eval_num(txt: str) -> float:
655
+ import sympy
656
+
657
+ allowed = {
658
+ k: getattr(sympy, k)
659
+ for k in [
660
+ "pi",
661
+ "E",
662
+ "exp",
663
+ "sqrt",
664
+ "log",
665
+ "sin",
666
+ "cos",
667
+ "tan",
668
+ "asin",
669
+ "acos",
670
+ "atan",
671
+ "Rational",
672
+ ]
673
+ if hasattr(sympy, k)
674
+ }
675
+ expr = sympy.sympify(txt, locals=allowed)
676
+ return float(expr.evalf())
677
+
678
+ def _extract_domain_and_exclusions(text: str):
679
+ """Return (domain_tuple|None, exclusions_list, text_without_domain).
680
+ Heuristic: find the first parenthesis block whose top-level content
681
+ splits into exactly two parts by a single top-level comma.
682
+ """
683
+ n = len(text)
684
+ i = 0
685
+ while i < n:
686
+ if text[i] == "(":
687
+ depth = 1
688
+ j = i + 1
689
+ while j < n and depth > 0:
690
+ ch = text[j]
691
+ if ch == "(":
692
+ depth += 1
693
+ elif ch == ")":
694
+ depth -= 1
695
+ j += 1
696
+ if depth != 0:
697
+ # unbalanced, give up
698
+ break
699
+ content = text[i + 1 : j - 1].strip()
700
+ # Find a single top-level comma in content
701
+ depth2 = 0
702
+ comma_index: int | None = None
703
+ for k, ch in enumerate(content):
704
+ if ch == "(":
705
+ depth2 += 1
706
+ elif ch == ")":
707
+ depth2 -= 1
708
+ elif ch == "," and depth2 == 0:
709
+ if comma_index is None:
710
+ comma_index = k
711
+ else:
712
+ # more than one top-level comma => not domain
713
+ comma_index = None
714
+ break
715
+ if comma_index is not None:
716
+ left = content[:comma_index].strip()
717
+ right = content[comma_index + 1 :].strip()
718
+ if left and right:
719
+ # Optional exclusions right after ) like \{a,b}
720
+ k2 = j
721
+ excl_list: List[float] = []
722
+ # skip whitespace
723
+ while k2 < n and text[k2].isspace():
724
+ k2 += 1
725
+ if k2 < n and text[k2] == "\\":
726
+ k2 += 1
727
+ while k2 < n and text[k2].isspace():
728
+ k2 += 1
729
+ if k2 < n and text[k2] == "{":
730
+ k2 += 1
731
+ excl_start = k2
732
+ # read until matching '}' (no nesting expected here)
733
+ while k2 < n and text[k2] != "}":
734
+ k2 += 1
735
+ excl_content = text[excl_start:k2]
736
+ if k2 < n and text[k2] == "}":
737
+ k2 += 1 # consume '}'
738
+ for tok in [
739
+ t.strip() for t in excl_content.split(",") if t.strip()
740
+ ]:
741
+ try:
742
+ excl_list.append(_sym_eval_num(tok))
743
+ except Exception:
744
+ pass
745
+ # Attempt numeric evaluation of endpoints
746
+ dom_tuple: Tuple[float, float] | None = None
747
+ try:
748
+ d0 = _sym_eval_num(left)
749
+ d1 = _sym_eval_num(right)
750
+ dom_tuple = (d0, d1)
751
+ except Exception:
752
+ dom_tuple = None
753
+ # Remove the consumed substring from original text
754
+ new_text = (text[:i] + text[k2:]).strip()
755
+ return dom_tuple, excl_list, new_text
756
+ # Move past this parenthesis group and continue scanning
757
+ i = j
758
+ continue
759
+ i += 1
760
+ return None, [], text
761
+
762
+ domain, excludes, s_wo_dom = _extract_domain_and_exclusions(s)
763
+ # Tokenize on commas to robustly drop empty segments created by domain removal
764
+ parts = [p.strip() for p in s_wo_dom.split(",") if p.strip()]
765
+ if parts:
766
+ expr = parts[0]
767
+ label = None
768
+ # Scan remaining tokens for label/color using heuristics
769
+ for tok in parts[1:]:
770
+ t = tok.strip()
771
+ if t.lower().startswith("label="):
772
+ if label is None:
773
+ label = t.split("=", 1)[1].strip()
774
+ continue
775
+ if t.lower().startswith("color="):
776
+ if color is None:
777
+ color = t.split("=", 1)[1].strip()
778
+ continue
779
+ if color is None and _looks_like_color(t):
780
+ color = t
781
+ elif label is None:
782
+ label = t
783
+ return expr, label, domain, excludes, color
784
+ # Only expression provided (or empty after cleanup)
785
+ return s_wo_dom.strip(), None, domain, excludes, None
786
+
787
+ for item in raw_fn_items:
788
+ expr, label, domain, excludes, color = _parse_function_item(item)
789
+ try:
790
+ functions.append(_compile_function(expr))
791
+ fn_exprs.append(expr)
792
+ fn_labels_list.append(label or "")
793
+ fn_domains_list.append(domain)
794
+ fn_exclusions_list.append(sorted(excludes))
795
+ fn_colors_list.append(color or "")
796
+ except Exception as ex:
797
+ return [
798
+ self.state_machine.reporter.error(
799
+ f"Ugyldig funksjon '{expr}': {ex}", line=self.lineno
800
+ )
801
+ ]
802
+
803
+ # ------------------------------------
804
+ # Unified numeric expression evaluator
805
+ # Supports:
806
+ # * Plain numbers
807
+ # * Arithmetic & SymPy functions (sqrt, pi, sin, ...)
808
+ # * References to previously defined function labels: f(2), g(pi/4+1)
809
+ # Limitations: nested user function calls deeper than 50 rewrites are blocked.
810
+ # ------------------------------------
811
+ _num_cache: Dict[str, float] = {}
812
+
813
+ def _eval_expr(val) -> float:
814
+ import sympy, re
815
+
816
+ if val is None:
817
+ raise ValueError("Empty value")
818
+ if isinstance(val, (int, float)):
819
+ return float(val)
820
+ s0 = str(val).strip()
821
+ if not s0:
822
+ raise ValueError("Blank numeric expression")
823
+ if s0 in _num_cache:
824
+ return _num_cache[s0]
825
+ s = s0
826
+ # Replace user function label calls iteratively.
827
+ # Allow nested parentheses now by a balanced scan: we match label( ... ) at top-level of that paren group.
828
+ pat = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\(")
829
+ # Simpler: fallback to previous non-nested approach but broaden attempt; for safety keep prior pattern.
830
+ pat_simple = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\(([^()]+)\)")
831
+ for _ in range(50):
832
+ m = pat_simple.search(s)
833
+ if not m:
834
+ break
835
+ lbl, arg_expr = m.group(1), m.group(2)
836
+ if lbl in fn_labels_list:
837
+ try:
838
+ arg_val = _eval_expr(arg_expr)
839
+ idx = fn_labels_list.index(lbl)
840
+ f = functions[idx]
841
+ yv = float(f([arg_val])[0])
842
+ s = s[: m.start()] + f"{yv}" + s[m.end() :]
843
+ continue
844
+ except Exception:
845
+ # leave unresolved for sympy
846
+ pass
847
+ # If not user function, just proceed to next occurrence
848
+ # Remove nothing to avoid infinite loop: break
849
+ break
850
+ allowed = {
851
+ k: getattr(sympy, k)
852
+ for k in [
853
+ "pi",
854
+ "E",
855
+ "exp",
856
+ "sqrt",
857
+ "log",
858
+ "sin",
859
+ "cos",
860
+ "tan",
861
+ "asin",
862
+ "acos",
863
+ "atan",
864
+ "Rational",
865
+ ]
866
+ if hasattr(sympy, k)
867
+ }
868
+ try:
869
+ expr = sympy.sympify(s, locals=allowed)
870
+ valf = float(expr.evalf())
871
+ _num_cache[s0] = valf
872
+ return valf
873
+ except Exception as e:
874
+ raise ValueError(f"Kunne ikke tolke numerisk uttrykk '{val}': {e}")
875
+
876
+ # Points
877
+ point_vals: List[Tuple[float, float]] = []
878
+ for p in lists.get("point", []):
879
+ lit = _safe_literal(p)
880
+ if isinstance(lit, (list, tuple)) and len(lit) == 2:
881
+ try:
882
+ x0 = _eval_expr(lit[0])
883
+ y0 = _eval_expr(lit[1])
884
+ point_vals.append((x0, y0))
885
+ except Exception:
886
+ pass
887
+ else:
888
+ # Support dynamic evaluation referencing previously defined function labels, e.g. (5, f(5))
889
+ # Simple pattern match for a parenthesized pair allowing arbitrary (non-comma) inner expressions.
890
+ ps = str(p).strip()
891
+ m_pair = re.match(r"^\(\s*([^,]+?)\s*,\s*([^,]+?)\s*\)$", ps)
892
+ if m_pair:
893
+ x_raw = m_pair.group(1).strip()
894
+ y_raw = m_pair.group(2).strip()
895
+ try:
896
+ x_val = _eval_expr(x_raw)
897
+ except Exception:
898
+ # If x itself references a function call label(arg)
899
+ m_fx = re.match(
900
+ r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([+-]?(?:\d+(?:\.\d+)?))\s*\)$",
901
+ x_raw,
902
+ )
903
+ if m_fx:
904
+ lbl = m_fx.group(1)
905
+ arg = float(m_fx.group(2))
906
+ try:
907
+ idx = fn_labels_list.index(lbl)
908
+ x_val = float(functions[idx]([arg])[0])
909
+ except Exception:
910
+ continue # give up on this point
911
+ else:
912
+ continue
913
+ # y may be a direct float or a function label call like f(5)
914
+ try:
915
+ y_val = _eval_expr(y_raw)
916
+ except Exception:
917
+ m_fy = re.match(
918
+ r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([+-]?(?:\d+(?:\.\d+)?))\s*\)$",
919
+ y_raw,
920
+ )
921
+ if not m_fy:
922
+ continue
923
+ lbl = m_fy.group(1)
924
+ arg = float(m_fy.group(2))
925
+ if lbl in fn_labels_list:
926
+ try:
927
+ idx = fn_labels_list.index(lbl)
928
+ y_val = float(functions[idx]([arg])[0])
929
+ except Exception:
930
+ continue
931
+ else:
932
+ continue
933
+ try:
934
+ point_vals.append((float(x_val), float(y_val)))
935
+ except Exception:
936
+ pass
937
+
938
+ # Tangents: entries like "(x0, f(x0))" or
939
+ # "(x0, f(x0)), dashed, red" -> draw tangent to f at x0
940
+ tangent_vals: List[Tuple[float, float, float, str | None, str | None]] = (
941
+ []
942
+ ) # (a, b, x0, style, color)
943
+ for t in lists.get("tangent", []):
944
+ ps = str(t).strip()
945
+ # Split on top-level commas so we can accept extra style/color tokens
946
+ depth_t = 0
947
+ cur_t: List[str] = []
948
+ parts_t: List[str] = []
949
+ for ch in ps:
950
+ if ch == "(":
951
+ depth_t += 1
952
+ cur_t.append(ch)
953
+ elif ch == ")":
954
+ depth_t -= 1
955
+ cur_t.append(ch)
956
+ elif ch == "," and depth_t == 0:
957
+ tok = "".join(cur_t).strip()
958
+ if tok:
959
+ parts_t.append(tok)
960
+ cur_t = []
961
+ else:
962
+ cur_t.append(ch)
963
+ tail_t = "".join(cur_t).strip()
964
+ if tail_t:
965
+ parts_t.append(tail_t)
966
+ if not parts_t:
967
+ continue
968
+
969
+ # First part must be the point pair (x0, f(x0))
970
+ m_pair = re.match(r"^\(\s*([^,]+?)\s*,\s*([^,]+?)\s*\)$", parts_t[0])
971
+ if not m_pair:
972
+ continue
973
+ x_raw = m_pair.group(1).strip()
974
+ y_raw = m_pair.group(2).strip()
975
+ # Expect y_raw to be a function label call like f(0.5)
976
+ m_fy = re.match(
977
+ r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([^()]+)\s*\)$",
978
+ y_raw,
979
+ )
980
+ if not m_fy:
981
+ continue
982
+ lbl = m_fy.group(1)
983
+ arg_expr = m_fy.group(2)
984
+ if lbl not in fn_labels_list:
985
+ continue
986
+ try:
987
+ x0 = _eval_expr(x_raw)
988
+ arg_val = _eval_expr(arg_expr)
989
+ except Exception:
990
+ continue
991
+ # Remaining parts: optional linestyle/color in any order (like vline/hline/line)
992
+ style_t: str | None = None
993
+ color_t: str | None = None
994
+ _allowed_styles_t = {"solid", "dotted", "dashed", "dashdot"}
995
+ for extra in parts_t[1:]:
996
+ tok = extra.strip().strip("'\"")
997
+ low = tok.lower()
998
+ if low in _allowed_styles_t and style_t is None:
999
+ style_t = low
1000
+ elif color_t is None:
1001
+ color_t = tok
1002
+ try:
1003
+ idx = fn_labels_list.index(lbl)
1004
+ f = functions[idx]
1005
+ import numpy as _np_t
1006
+
1007
+ # Finite-difference derivative around x0
1008
+ h = max(1e-5, 1e-5 * (1.0 + abs(x0)))
1009
+ y_plus = float(f(_np_t.array([x0 + h], dtype=float))[0])
1010
+ y_minus = float(f(_np_t.array([x0 - h], dtype=float))[0])
1011
+ a_t = (y_plus - y_minus) / (2 * h)
1012
+ y0 = float(f(_np_t.array([x0], dtype=float))[0])
1013
+ b_t = y0 - a_t * x0
1014
+ tangent_vals.append((a_t, b_t, x0, style_t, color_t))
1015
+ except Exception:
1016
+ continue
1017
+
1018
+ # Annotations: [(xytext), (xy), "text", arc] OR without outer brackets:
1019
+ # (xytext), (xy), "text"[, arc]
1020
+ ann_vals: List[Tuple[Tuple[float, float], Tuple[float, float], str, float]] = []
1021
+
1022
+ def _annotate_fallback_parse(raw: str):
1023
+ """Fallback parser for annotate lines containing arbitrary expressions.
1024
+ Expected pattern: (expr_x1, expr_y1), (expr_x2, expr_y2), "text"[, arc]
1025
+ Returns list of ( (x1,y1), (x2,y2), text, arc_expr|None ).
1026
+ """
1027
+ s = raw.strip()
1028
+ out: List[Tuple[Tuple[str, str], Tuple[str, str], str, str | None]] = []
1029
+
1030
+ # Find first two balanced tuples
1031
+ def _grab_tuple(start_index: int) -> Tuple[int, int, str] | None:
1032
+ if start_index >= len(s) or s[start_index] != "(":
1033
+ return None
1034
+ depth = 0
1035
+ for j in range(start_index, len(s)):
1036
+ if s[j] == "(":
1037
+ depth += 1
1038
+ elif s[j] == ")":
1039
+ depth -= 1
1040
+ if depth == 0:
1041
+ inner = s[start_index + 1 : j]
1042
+ return (start_index, j, inner)
1043
+ return None
1044
+
1045
+ # locate first '('
1046
+ i1 = s.find("(")
1047
+ if i1 == -1:
1048
+ return out
1049
+ t1 = _grab_tuple(i1)
1050
+ if not t1:
1051
+ return out
1052
+ i2_search = t1[1] + 1
1053
+ # skip commas/space
1054
+ while i2_search < len(s) and s[i2_search] in " ,":
1055
+ i2_search += 1
1056
+ if i2_search >= len(s) or s[i2_search] != "(":
1057
+ return out
1058
+ t2 = _grab_tuple(i2_search)
1059
+ if not t2:
1060
+ return out
1061
+ rest = s[t2[1] + 1 :].strip()
1062
+
1063
+ # Split tuple inners on top-level comma
1064
+ def _split_pair(inner: str) -> Tuple[str, str] | None:
1065
+ depth = 0
1066
+ for k, ch in enumerate(inner):
1067
+ if ch == "(":
1068
+ depth += 1
1069
+ elif ch == ")":
1070
+ depth -= 1
1071
+ elif ch == "," and depth == 0:
1072
+ left = inner[:k].strip()
1073
+ right = inner[k + 1 :].strip()
1074
+ if left and right:
1075
+ return (left, right)
1076
+ return None
1077
+
1078
+ p1 = _split_pair(t1[2])
1079
+ p2 = _split_pair(t2[2])
1080
+ if not (p1 and p2):
1081
+ return out
1082
+ # Extract quoted text
1083
+ # Regex to capture either a double-quoted or single-quoted string
1084
+ m_txt = re.search(
1085
+ r"\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|'([^'\\]*(?:\\.[^'\\]*)*)'",
1086
+ rest,
1087
+ )
1088
+ if not m_txt:
1089
+ return out
1090
+ text = m_txt.group(1) if m_txt.group(1) is not None else m_txt.group(2)
1091
+ after = rest[m_txt.end() :].strip().lstrip(",").strip()
1092
+ arc_expr = after if after else None
1093
+ out.append(((p1[0], p1[1]), (p2[0], p2[1]), text, arc_expr))
1094
+ return out
1095
+
1096
+ for a in lists.get("annotate", []):
1097
+ lit = _safe_literal(a)
1098
+ # If user omitted surrounding brackets, try wrapping in [] for parsing
1099
+ if not (isinstance(lit, (list, tuple)) and len(lit) >= 3):
1100
+ lit_wrapped = _safe_literal(f"[{a}]")
1101
+ if isinstance(lit_wrapped, list) and len(lit_wrapped) >= 3:
1102
+ lit = lit_wrapped
1103
+ added_before = len(ann_vals)
1104
+ if isinstance(lit, (list, tuple)) and len(lit) >= 3:
1105
+ xytext, xy, text = lit[0], lit[1], lit[2]
1106
+ # arc (arrow curvature) may be an expression
1107
+ try:
1108
+ arc = _eval_expr(str(lit[3])) if len(lit) > 3 else 0.3
1109
+ except Exception:
1110
+ try:
1111
+ arc = float(lit[3]) if len(lit) > 3 else 0.3
1112
+ except Exception:
1113
+ arc = 0.3
1114
+ try:
1115
+ # Allow expression coordinates. Expect xytext and xy to be (x,y)-like iterables
1116
+ xyt0 = _eval_expr(str(xytext[0]))
1117
+ xyt1 = _eval_expr(str(xytext[1]))
1118
+ xy0 = _eval_expr(str(xy[0]))
1119
+ xy1 = _eval_expr(str(xy[1]))
1120
+ xytext = (float(xyt0), float(xyt1))
1121
+ xy = (float(xy0), float(xy1))
1122
+ text = str(text)
1123
+ ann_vals.append((xytext, xy, text, float(arc)))
1124
+ except Exception:
1125
+ # Fallback: attempt plain float conversion
1126
+ try:
1127
+ xytext = (float(xytext[0]), float(xytext[1]))
1128
+ xy = (float(xy[0]), float(xy[1]))
1129
+ text = str(text)
1130
+ ann_vals.append((xytext, xy, text, float(arc)))
1131
+ except Exception:
1132
+ pass
1133
+ # Fallback parsing if nothing appended
1134
+ if len(ann_vals) == added_before:
1135
+ for (
1136
+ (x1_expr, y1_expr),
1137
+ (x2_expr, y2_expr),
1138
+ text_s,
1139
+ arc_expr,
1140
+ ) in _annotate_fallback_parse(str(a)):
1141
+ try:
1142
+ x1 = _eval_expr(x1_expr)
1143
+ y1 = _eval_expr(y1_expr)
1144
+ except Exception:
1145
+ continue
1146
+ try:
1147
+ x2 = _eval_expr(x2_expr)
1148
+ y2 = _eval_expr(y2_expr)
1149
+ except Exception:
1150
+ continue
1151
+ # arc expression optional
1152
+ arc_val = 0.3
1153
+ if arc_expr:
1154
+ try:
1155
+ arc_val = _eval_expr(arc_expr)
1156
+ except Exception:
1157
+ try:
1158
+ arc_val = float(arc_expr)
1159
+ except Exception:
1160
+ pass
1161
+ ann_vals.append(
1162
+ (
1163
+ (float(x1), float(y1)),
1164
+ (float(x2), float(y2)),
1165
+ str(text_s),
1166
+ float(arc_val),
1167
+ )
1168
+ )
1169
+
1170
+ # Text: x, y, text, optional positioning, optional bbox flag
1171
+ # Accepted forms:
1172
+ # - [x, y, text]
1173
+ # - [x, y, text, pos]
1174
+ # - [x, y, text, bbox] # where bbox can be 'bbox' or a boolean (true/false)
1175
+ # - [x, y, text, pos, bbox]
1176
+ # CSV fallback supports the same arities; for 4 tokens, the 4th can be pos or bbox
1177
+ text_vals: List[Tuple[float, float, str, str, bool]] = []
1178
+ for t in lists.get("text", []):
1179
+ lit = _safe_literal(t)
1180
+ if isinstance(lit, (list, tuple)) and (3 <= len(lit) <= 5):
1181
+ try:
1182
+ # Allow expressions for x and y
1183
+ x = _eval_expr(str(lit[0]))
1184
+ y = _eval_expr(str(lit[1]))
1185
+ text = str(lit[2])
1186
+ pos = "top-left"
1187
+ bbox_flag = False
1188
+ if len(lit) == 4:
1189
+ token = lit[3]
1190
+ # If token is an explicit bbox flag
1191
+ if isinstance(token, str) and token.strip().lower() == "bbox":
1192
+ bbox_flag = True
1193
+ else:
1194
+ b = _parse_bool(token, default=None)
1195
+ if isinstance(b, bool):
1196
+ bbox_flag = bool(b)
1197
+ else:
1198
+ pos = str(token)
1199
+ elif len(lit) == 5:
1200
+ pos = str(lit[3])
1201
+ token = lit[4]
1202
+ if isinstance(token, str) and token.strip().lower() == "bbox":
1203
+ bbox_flag = True
1204
+ else:
1205
+ b = _parse_bool(token, default=None)
1206
+ if isinstance(b, bool):
1207
+ bbox_flag = bool(b)
1208
+ text_vals.append((x, y, text, pos, bbox_flag))
1209
+ continue
1210
+ except Exception:
1211
+ pass
1212
+ # Fallback: allow unquoted tokens like top-left using a CSV-style parse
1213
+ try:
1214
+ import csv
1215
+
1216
+ s = str(t).strip()
1217
+ # strip surrounding brackets/parentheses if present
1218
+ if (s.startswith("[") and s.endswith("]")) or (
1219
+ s.startswith("(") and s.endswith(")")
1220
+ ):
1221
+ s = s[1:-1]
1222
+ # parse as a single CSV row
1223
+ row = next(csv.reader([s], skipinitialspace=True))
1224
+ if len(row) in (3, 4, 5):
1225
+ # Evaluate x,y as expressions
1226
+ x = _eval_expr(row[0].strip())
1227
+ y = _eval_expr(row[1].strip())
1228
+ text = row[2].strip()
1229
+ pos_keys = {
1230
+ "top-left",
1231
+ "top-right",
1232
+ "bottom-left",
1233
+ "bottom-right",
1234
+ "top-center",
1235
+ "bottom-center",
1236
+ "center-left",
1237
+ "center-right",
1238
+ }
1239
+ pos = "top-left"
1240
+ bbox_flag = False
1241
+ if len(row) == 4:
1242
+ tok = row[3].strip()
1243
+ if tok.lower() in pos_keys:
1244
+ pos = tok
1245
+ else:
1246
+ if tok.strip().lower() == "bbox":
1247
+ bbox_flag = True
1248
+ else:
1249
+ b = _parse_bool(tok, default=None)
1250
+ if isinstance(b, bool):
1251
+ bbox_flag = bool(b)
1252
+ else:
1253
+ # treat as position if not a boolean
1254
+ pos = tok
1255
+ elif len(row) == 5:
1256
+ pos = row[3].strip()
1257
+ tok = row[4].strip()
1258
+ if tok.strip().lower() == "bbox":
1259
+ bbox_flag = True
1260
+ else:
1261
+ b = _parse_bool(tok, default=None)
1262
+ if isinstance(b, bool):
1263
+ bbox_flag = bool(b)
1264
+ text_vals.append((x, y, text, pos, bbox_flag))
1265
+ except Exception:
1266
+ pass
1267
+
1268
+ # vlines: x[, ymin, ymax][, linestyle][, color] (style/color any order)
1269
+ vline_vals: List[Tuple[float, float | None, float | None, str | None, str | None]] = []
1270
+ _allowed_styles = {"solid", "dotted", "dashed", "dashdot"}
1271
+ for v in lists.get("vline", []):
1272
+ lit = _safe_literal(v)
1273
+ tokens: List[str] = []
1274
+ if isinstance(lit, (list, tuple)):
1275
+ tokens = [str(x).strip() for x in lit]
1276
+ else:
1277
+ tokens = [p.strip() for p in str(v).split(",") if p.strip()]
1278
+
1279
+ nums: List[float] = [] # evaluated numeric tokens (expressions allowed)
1280
+ extras: List[str] = [] # potential style/color tokens
1281
+ for t in tokens:
1282
+ # Attempt expression evaluation (supports arithmetic & function labels)
1283
+ try:
1284
+ val = _eval_expr(t)
1285
+ nums.append(val)
1286
+ continue
1287
+ except Exception:
1288
+ pass
1289
+ extras.append(t)
1290
+ x_val: float | None = None
1291
+ y0_val: float | None = None
1292
+ y1_val: float | None = None
1293
+ if len(nums) >= 1:
1294
+ x_val = nums[0]
1295
+ if len(nums) >= 3:
1296
+ y0_val, y1_val = nums[1], nums[2]
1297
+
1298
+ style: str | None = None
1299
+ color: str | None = None
1300
+ for e in extras:
1301
+ el = e.lower()
1302
+ if el in _allowed_styles and style is None:
1303
+ style = e
1304
+ elif color is None:
1305
+ color = e
1306
+ if x_val is not None:
1307
+ vline_vals.append((x_val, y0_val, y1_val, style, color))
1308
+
1309
+ # polygons: (x,y), (x,y), ... [ , show_vertices]
1310
+ # Extended: each coordinate may be an expression with user function calls.
1311
+ poly_vals: List[Tuple[List[Tuple[float, float]], bool]] = []
1312
+
1313
+ # We avoid a complex fragile regex here and instead perform a small
1314
+ # balanced-parentheses scan so expressions like (2*sqrt(5), f(3+pi/4)) work.
1315
+ def _extract_coord_pairs(seq: str) -> List[Tuple[str, str]]:
1316
+ pairs: List[Tuple[str, str]] = []
1317
+ i = 0
1318
+ n = len(seq)
1319
+ while i < n:
1320
+ if seq[i] == "(": # potential tuple start
1321
+ depth = 0
1322
+ j = i
1323
+ while j < n:
1324
+ ch = seq[j]
1325
+ if ch == "(":
1326
+ depth += 1
1327
+ elif ch == ")":
1328
+ depth -= 1
1329
+ if depth == 0:
1330
+ inner = seq[i + 1 : j].strip()
1331
+ # split inner on a top-level comma
1332
+ depth2 = 0
1333
+ comma_index = -1
1334
+ for k, ch2 in enumerate(inner):
1335
+ if ch2 == "(":
1336
+ depth2 += 1
1337
+ elif ch2 == ")":
1338
+ depth2 -= 1
1339
+ elif ch2 == "," and depth2 == 0:
1340
+ comma_index = k
1341
+ break
1342
+ if comma_index != -1:
1343
+ x_expr = inner[:comma_index].strip()
1344
+ y_expr = inner[comma_index + 1 :].strip()
1345
+ if x_expr and y_expr:
1346
+ pairs.append((x_expr, y_expr))
1347
+ i = j # jump to end of tuple
1348
+ break
1349
+ j += 1
1350
+ i += 1
1351
+ return pairs
1352
+
1353
+ for p in lists.get("polygon", []):
1354
+ s = str(p).strip()
1355
+ show_vertices = False
1356
+ if re.search(r"(^|,)\s*show_vertices\s*(?=,|$)", s, flags=re.IGNORECASE):
1357
+ show_vertices = True
1358
+ s = re.sub(r"(^|,)\s*show_vertices\s*(?=,|$)", ",", s, flags=re.IGNORECASE)
1359
+ s = re.sub(r",{2,}", ",", s).strip().strip(",")
1360
+ pts: List[Tuple[float, float]] = []
1361
+ for x_expr, y_expr in _extract_coord_pairs(s):
1362
+ try:
1363
+ xv = _eval_expr(x_expr)
1364
+ yv = _eval_expr(y_expr)
1365
+ pts.append((xv, yv))
1366
+ except Exception:
1367
+ # Ignore malformed or unevaluable pair
1368
+ pass
1369
+ if pts:
1370
+ poly_vals.append((pts, show_vertices))
1371
+
1372
+ # Re-introduce a plain numeric tuple matcher for other primitives still expecting numeric-only coordinates.
1373
+ tup_pat = re.compile(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)")
1374
+
1375
+ # bar: (x, y), length, orientation
1376
+ # Accept both literal list/tuple and CSV-like fallback
1377
+ bar_vals: List[Tuple[Tuple[float, float], float, str]] = []
1378
+ for b in lists.get("bar", []):
1379
+ lit = _safe_literal(b)
1380
+ if isinstance(lit, (list, tuple)) and len(lit) >= 3:
1381
+ try:
1382
+ xy_raw, length_raw, orient_raw = lit[0], lit[1], lit[2]
1383
+ xy = (float(xy_raw[0]), float(xy_raw[1]))
1384
+ length = float(length_raw)
1385
+ orientation = str(orient_raw).strip().lower()
1386
+ if orientation in {"h", "hor", "horiz", "horizontal"}:
1387
+ orientation = "horizontal"
1388
+ elif orientation in {"v", "vert", "vertical"}:
1389
+ orientation = "vertical"
1390
+ bar_vals.append((xy, length, orientation))
1391
+ continue
1392
+ except Exception:
1393
+ pass
1394
+ # Fallback CSV: (x,y), length, orientation
1395
+ try:
1396
+ import csv as _csv
1397
+
1398
+ s = str(b).strip()
1399
+ # Attempt to peel off the first tuple
1400
+ m = tup_pat.search(s)
1401
+ if not m:
1402
+ continue
1403
+ x = float(m.group(1))
1404
+ y = float(m.group(2))
1405
+ # Remove tuple substring, then split the rest by commas
1406
+ a, c = m.span()
1407
+ rest = (s[:a] + s[c:]).strip()
1408
+ parts = [p.strip() for p in rest.split(",") if p.strip()]
1409
+ if len(parts) >= 2:
1410
+ length = float(parts[0])
1411
+ orientation = parts[1].strip().lower()
1412
+ if orientation in {"h", "hor", "horiz", "horizontal"}:
1413
+ orientation = "horizontal"
1414
+ elif orientation in {"v", "vert", "vertical"}:
1415
+ orientation = "vertical"
1416
+ bar_vals.append(((x, y), length, orientation))
1417
+ except Exception:
1418
+ pass
1419
+
1420
+ hline_vals: List[Tuple[float, float | None, float | None, str | None, str | None]] = []
1421
+ for h in lists.get("hline", []):
1422
+ lit = _safe_literal(h)
1423
+ tokens_h: List[str] = []
1424
+ if isinstance(lit, (list, tuple)):
1425
+ tokens_h = [str(x).strip() for x in lit]
1426
+ else:
1427
+ tokens_h = [p.strip() for p in str(h).split(",") if p.strip()]
1428
+
1429
+ nums_h: List[float] = [] # numeric (expressions) for y, x0, x1
1430
+ extras_h: List[str] = [] # style/color tokens
1431
+ for t in tokens_h:
1432
+ try:
1433
+ val = _eval_expr(t)
1434
+ nums_h.append(val)
1435
+ continue
1436
+ except Exception:
1437
+ pass
1438
+ extras_h.append(t)
1439
+ y_val: float | None = None
1440
+ x0_val: float | None = None
1441
+ x1_val: float | None = None
1442
+ if len(nums_h) >= 1:
1443
+ y_val = nums_h[0]
1444
+ if len(nums_h) >= 3:
1445
+ x0_val, x1_val = nums_h[1], nums_h[2]
1446
+
1447
+ style_h: str | None = None
1448
+ color_h: str | None = None
1449
+ for e in extras_h:
1450
+ el = e.lower()
1451
+ if el in _allowed_styles and style_h is None:
1452
+ style_h = e
1453
+ elif color_h is None:
1454
+ color_h = e
1455
+ if y_val is not None:
1456
+ hline_vals.append((y_val, x0_val, x1_val, style_h, color_h))
1457
+
1458
+ # lines: a, b, color, linestyle OR a, (x, y), color, linestyle
1459
+ # color and linestyle optional and order-independent; linestyle defaults to dashed
1460
+ line_vals: List[Tuple[float, float, str | None, str | None]] = [] # (a, b, style, color)
1461
+ _allowed_styles_line = {"solid", "dotted", "dashed", "dashdot"}
1462
+
1463
+ def _split_top_level_line(val: str) -> List[str]:
1464
+ s = str(val or "").strip()
1465
+ if not s:
1466
+ return []
1467
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
1468
+ s = s[1:-1].strip()
1469
+ out: List[str] = []
1470
+ cur: List[str] = []
1471
+ depth = 0
1472
+ for ch in s:
1473
+ if ch in "([{":
1474
+ depth += 1
1475
+ cur.append(ch)
1476
+ elif ch in ")]}":
1477
+ depth = max(0, depth - 1)
1478
+ cur.append(ch)
1479
+ elif ch == "," and depth == 0:
1480
+ part = "".join(cur).strip()
1481
+ if part:
1482
+ out.append(part)
1483
+ cur = []
1484
+ else:
1485
+ cur.append(ch)
1486
+ tail = "".join(cur).strip()
1487
+ if tail:
1488
+ out.append(tail)
1489
+ return out
1490
+
1491
+ num_re_line = r"[+-]?\d+(?:\.\d+)?"
1492
+ tup_pat_line = re.compile(rf"\(\s*({num_re_line})\s*,\s*({num_re_line})\s*\)")
1493
+ for l in lists.get("line", []):
1494
+ a_val: float | None = None
1495
+ b_val: float | None = None
1496
+ style_line: str | None = None
1497
+ color_line: str | None = None
1498
+ lit_line = _safe_literal(l)
1499
+ if isinstance(lit_line, (list, tuple)) and len(lit_line) >= 2:
1500
+ try:
1501
+ a_val = float(lit_line[0])
1502
+ except Exception:
1503
+ a_val = None
1504
+ second = lit_line[1]
1505
+ if isinstance(second, (list, tuple)) and len(second) == 2:
1506
+ try:
1507
+ x0p = float(second[0])
1508
+ y0p = float(second[1])
1509
+ if a_val is not None:
1510
+ b_val = y0p - a_val * x0p
1511
+ except Exception:
1512
+ pass
1513
+ else:
1514
+ try:
1515
+ b_val = float(second)
1516
+ except Exception:
1517
+ pass
1518
+ for extra in list(lit_line[2:]):
1519
+ if isinstance(extra, str):
1520
+ e = extra.strip().strip("\"'")
1521
+ if e.lower() in _allowed_styles_line and style_line is None:
1522
+ style_line = e.lower()
1523
+ elif color_line is None:
1524
+ color_line = e
1525
+ if a_val is not None and b_val is not None:
1526
+ line_vals.append((a_val, b_val, style_line, color_line))
1527
+ continue
1528
+ parts = _split_top_level_line(str(l))
1529
+ if len(parts) >= 2:
1530
+ try:
1531
+ a_val = float(parts[0])
1532
+ except Exception:
1533
+ a_val = None
1534
+ mpt = tup_pat_line.match(parts[1])
1535
+ if mpt is not None:
1536
+ try:
1537
+ x0p = float(mpt.group(1))
1538
+ y0p = float(mpt.group(2))
1539
+ if a_val is not None:
1540
+ b_val = y0p - a_val * x0p
1541
+ except Exception:
1542
+ pass
1543
+ else:
1544
+ try:
1545
+ b_val = float(parts[1])
1546
+ except Exception:
1547
+ pass
1548
+ for extra in parts[2:]:
1549
+ e = str(extra).strip().strip("\"'")
1550
+ if e.lower() in _allowed_styles_line and style_line is None:
1551
+ style_line = e.lower()
1552
+ elif color_line is None:
1553
+ color_line = e
1554
+ if a_val is not None and b_val is not None:
1555
+ line_vals.append((a_val, b_val, style_line, color_line))
1556
+
1557
+ # fill-polygons: (x,y), (x,y), ... [, color] [, alpha]
1558
+ # Extended: coordinate expressions support arithmetic & function calls like polygons.
1559
+ # Defaults: color -> plotmath.COLORS.get("blue"), alpha -> 0.1
1560
+ poly_fill_vals: List[Tuple[List[Tuple[float, float]], str | None, float | None]] = []
1561
+ for fp in lists.get("fill-polygon", []):
1562
+ s = str(fp).strip()
1563
+ pts_fp: List[Tuple[float, float]] = []
1564
+ # Reuse polygon balanced-parentheses extractor defined earlier.
1565
+ # We reconstruct remaining extras by removing the matched tuple spans.
1566
+ consumed: List[Tuple[int, int]] = []
1567
+ for x_expr, y_expr in _extract_coord_pairs(s):
1568
+ # Find the substring to mark as consumed (naively search first occurrence of '(' + content + ')')
1569
+ pattern = f"({x_expr},{y_expr})" # simplified; if spaces existed they were stripped
1570
+ # We'll just evaluate; for extras we approximate removal by replacing once later.
1571
+ try:
1572
+ xv = _eval_expr(x_expr)
1573
+ yv = _eval_expr(y_expr)
1574
+ pts_fp.append((xv, yv))
1575
+ except Exception:
1576
+ pass
1577
+ # Remove coordinate tuples crudely: replace occurrences of '(...)' that correspond to points
1578
+ rest = s
1579
+ # Simple loop removing parenthesized pairs counted earlier; risk: may remove unrelated parentheses if same text repeats
1580
+ # but acceptable for directive usage.
1581
+ # A safer approach would replicate extraction with span tracking; to keep patch minimal we do a regex wipe of tuples.
1582
+ rest = re.sub(r"\([^()]*?,[^()]*?\)", "", rest)
1583
+ rest = re.sub(r",{2,}", ",", rest)
1584
+ extras = [tok.strip() for tok in rest.split(",") if tok.strip()]
1585
+ color_fp: str | None = None
1586
+ alpha_fp: float | None = None
1587
+ # Interpret extras in any order: first numeric -> alpha, first non-numeric -> color
1588
+ for tok in extras:
1589
+ if alpha_fp is None:
1590
+ try:
1591
+ alpha_fp = _eval_expr(tok)
1592
+ continue
1593
+ except Exception:
1594
+ pass
1595
+ if color_fp is None:
1596
+ color_fp = tok
1597
+ # Stop early if both parsed
1598
+ if color_fp is not None and alpha_fp is not None:
1599
+ break
1600
+ if pts_fp:
1601
+ poly_fill_vals.append((pts_fp, color_fp, alpha_fp))
1602
+
1603
+ # line-segment: (x1,y1), (x2,y2)[, linestyle][, color] (style/color optional, any order)
1604
+ line_segment_vals: List[
1605
+ Tuple[Tuple[float, float], Tuple[float, float], str | None, str | None]
1606
+ ] = []
1607
+ _allowed_seg_styles = {"solid", "dotted", "dashed", "dashdot"}
1608
+ for ls in lists.get("line-segment", []):
1609
+ s = str(ls).strip()
1610
+ # Use the same balanced extractor as polygons but ensure exactly two points are taken.
1611
+ pairs = _extract_coord_pairs(s)
1612
+ if len(pairs) < 2:
1613
+ continue
1614
+ pcoords: List[Tuple[float, float]] = []
1615
+ for x_expr, y_expr in pairs[:2]:
1616
+ try:
1617
+ xv = _eval_expr(x_expr)
1618
+ yv = _eval_expr(y_expr)
1619
+ pcoords.append((float(xv), float(yv)))
1620
+ except Exception:
1621
+ pcoords = []
1622
+ break
1623
+ if len(pcoords) != 2:
1624
+ continue
1625
+ # Precisely remove the first two top-level tuples (with balanced parentheses)
1626
+ spans: List[Tuple[int, int]] = []
1627
+ depth = 0
1628
+ i = 0
1629
+ n = len(s)
1630
+ while i < n and len(spans) < 2:
1631
+ if s[i] == "(":
1632
+ depth = 1
1633
+ j = i + 1
1634
+ while j < n and depth > 0:
1635
+ if s[j] == "(":
1636
+ depth += 1
1637
+ elif s[j] == ")":
1638
+ depth -= 1
1639
+ j += 1
1640
+ if depth == 0:
1641
+ # captured tuple from i to j
1642
+ inner = s[i + 1 : j - 1]
1643
+ # verify it contains a top-level comma (treat as coordinate tuple)
1644
+ d2 = 0
1645
+ has_comma = False
1646
+ for ch in inner:
1647
+ if ch == "(":
1648
+ d2 += 1
1649
+ elif ch == ")":
1650
+ d2 -= 1
1651
+ elif ch == "," and d2 == 0:
1652
+ has_comma = True
1653
+ break
1654
+ if has_comma:
1655
+ spans.append((i, j))
1656
+ i = j
1657
+ else:
1658
+ i += 1
1659
+ # Build rest excluding spans
1660
+ if spans:
1661
+ parts_rest: List[str] = []
1662
+ last = 0
1663
+ for a, b in spans:
1664
+ if a > last:
1665
+ parts_rest.append(s[last:a])
1666
+ last = b
1667
+ if last < len(s):
1668
+ parts_rest.append(s[last:])
1669
+ rest = "".join(parts_rest)
1670
+ else:
1671
+ rest = s
1672
+ rest = re.sub(r",{2,}", ",", rest)
1673
+ tokens = [tok.strip().strip("'\"") for tok in rest.split(",") if tok.strip()]
1674
+ style_seg: str | None = None
1675
+ color_seg: str | None = None
1676
+ for tok in tokens:
1677
+ low = tok.lower()
1678
+ if low in _allowed_seg_styles and style_seg is None:
1679
+ style_seg = low
1680
+ continue
1681
+ if color_seg is None:
1682
+ # Accept token as color (will map later during draw)
1683
+ color_seg = tok
1684
+ line_segment_vals.append((pcoords[0], pcoords[1], style_seg, color_seg))
1685
+
1686
+ # axis commands: allow repeated keys like axis: equal / axis: off
1687
+ axis_cmds: List[str] = []
1688
+ for a in lists.get("axis", []):
1689
+ s = str(a).strip()
1690
+ # Allow comma-separated in one line as a convenience
1691
+ parts = [part.strip() for part in s.split(",") if part.strip()]
1692
+ for part in parts:
1693
+ # strip optional quotes
1694
+ if (part.startswith("'") and part.endswith("'")) or (
1695
+ part.startswith('"') and part.endswith('"')
1696
+ ):
1697
+ part = part[1:-1].strip()
1698
+ if part:
1699
+ axis_cmds.append(part)
1700
+
1701
+ # vectors: x, y, dx, dy[, color] with expression support
1702
+ vector_vals: List[Tuple[float, float, float, float, str]] = []
1703
+ for vline in lists.get("vector", []):
1704
+ s = str(vline).strip()
1705
+ # allow surrounding brackets/parentheses
1706
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
1707
+ s = s[1:-1].strip()
1708
+ # split by top-level commas (vectors unlikely to embed parentheses beyond simple expressions, but keep safe)
1709
+ depth_vec = 0
1710
+ cur_tok: List[str] = []
1711
+ parts: List[str] = []
1712
+ for ch in s:
1713
+ if ch == "(":
1714
+ depth_vec += 1
1715
+ cur_tok.append(ch)
1716
+ elif ch == ")":
1717
+ depth_vec -= 1
1718
+ cur_tok.append(ch)
1719
+ elif ch == "," and depth_vec == 0:
1720
+ tok = "".join(cur_tok).strip()
1721
+ if tok:
1722
+ parts.append(tok)
1723
+ cur_tok = []
1724
+ else:
1725
+ cur_tok.append(ch)
1726
+ tail_tok = "".join(cur_tok).strip()
1727
+ if tail_tok:
1728
+ parts.append(tail_tok)
1729
+ if len(parts) < 4:
1730
+ continue
1731
+ try:
1732
+ x_v = float(_eval_expr(parts[0]))
1733
+ y_v = float(_eval_expr(parts[1]))
1734
+ dx_v = float(_eval_expr(parts[2]))
1735
+ dy_v = float(_eval_expr(parts[3]))
1736
+ color_v = parts[4] if len(parts) >= 5 and parts[4] else "black"
1737
+ vector_vals.append((x_v, y_v, dx_v, dy_v, color_v))
1738
+ except Exception:
1739
+ # skip silently to preserve robustness
1740
+ continue
1741
+
1742
+ # angle-arc: (x, y), radius, start_angle_deg, end_angle_deg[, linestyle][, color]
1743
+ # Expression support for center, radius and angles; optional linestyle/color tokens in any order after the first three numeric expressions.
1744
+ angle_arcs: List[Tuple[float, float, float, float, float, str | None, str | None]] = []
1745
+ _allowed_arc_styles = {"solid", "dotted", "dashed", "dashdot"}
1746
+ for arc in lists.get("angle-arc", []):
1747
+ raw_arc = str(arc).strip()
1748
+ # Find first balanced parenthesis group for center
1749
+ idx_arc = raw_arc.find("(")
1750
+ if idx_arc == -1:
1751
+ continue
1752
+ depth_arc = 0
1753
+ end_center = -1
1754
+ for j in range(idx_arc, len(raw_arc)):
1755
+ ch = raw_arc[j]
1756
+ if ch == "(":
1757
+ depth_arc += 1
1758
+ elif ch == ")":
1759
+ depth_arc -= 1
1760
+ if depth_arc == 0:
1761
+ end_center = j
1762
+ break
1763
+ if end_center == -1:
1764
+ continue
1765
+ center_inner = raw_arc[idx_arc + 1 : end_center]
1766
+ # Split center_inner by top-level comma
1767
+ depth_c = 0
1768
+ cur_bits: List[str] = []
1769
+ center_parts: List[str] = []
1770
+ for ch in center_inner:
1771
+ if ch == "(":
1772
+ depth_c += 1
1773
+ cur_bits.append(ch)
1774
+ elif ch == ")":
1775
+ depth_c -= 1
1776
+ cur_bits.append(ch)
1777
+ elif ch == "," and depth_c == 0:
1778
+ token = "".join(cur_bits).strip()
1779
+ if token:
1780
+ center_parts.append(token)
1781
+ cur_bits = []
1782
+ else:
1783
+ cur_bits.append(ch)
1784
+ tail_cp = "".join(cur_bits).strip()
1785
+ if tail_cp:
1786
+ center_parts.append(tail_cp)
1787
+ if len(center_parts) != 2:
1788
+ continue
1789
+ rest_arc = raw_arc[end_center + 1 :].lstrip(",").strip()
1790
+ if not rest_arc:
1791
+ continue
1792
+ # Split rest by top-level commas
1793
+ depth_r = 0
1794
+ cur_r: List[str] = []
1795
+ tokens_r: List[str] = []
1796
+ for ch in rest_arc:
1797
+ if ch == "(":
1798
+ depth_r += 1
1799
+ cur_r.append(ch)
1800
+ elif ch == ")":
1801
+ depth_r -= 1
1802
+ cur_r.append(ch)
1803
+ elif ch == "," and depth_r == 0:
1804
+ tk = "".join(cur_r).strip()
1805
+ if tk:
1806
+ tokens_r.append(tk)
1807
+ cur_r = []
1808
+ else:
1809
+ cur_r.append(ch)
1810
+ tail_r = "".join(cur_r).strip()
1811
+ if tail_r:
1812
+ tokens_r.append(tail_r)
1813
+ if len(tokens_r) < 3:
1814
+ continue
1815
+ radius_expr = tokens_r[0]
1816
+ start_expr = tokens_r[1]
1817
+ end_expr = tokens_r[2]
1818
+ style_arc: str | None = None
1819
+ color_arc: str | None = None
1820
+ for extra_tok in tokens_r[3:]:
1821
+ low = extra_tok.lower()
1822
+ if low in _allowed_arc_styles and style_arc is None:
1823
+ style_arc = low
1824
+ elif color_arc is None:
1825
+ color_arc = extra_tok
1826
+ try:
1827
+ cx_val = float(_eval_expr(center_parts[0]))
1828
+ cy_val = float(_eval_expr(center_parts[1]))
1829
+ r_val = float(_eval_expr(radius_expr))
1830
+ if r_val <= 0:
1831
+ continue
1832
+ start_deg_val = float(_eval_expr(start_expr))
1833
+ end_deg_val = float(_eval_expr(end_expr))
1834
+ angle_arcs.append(
1835
+ (
1836
+ cx_val,
1837
+ cy_val,
1838
+ r_val,
1839
+ start_deg_val,
1840
+ end_deg_val,
1841
+ style_arc,
1842
+ color_arc,
1843
+ )
1844
+ )
1845
+ except Exception:
1846
+ continue
1847
+
1848
+ # circles: (x,y), radius[, linestyle][, color] (style/color optional, any order)
1849
+ # Accept expressions for x, y, radius. Optional tokens may appear in any order
1850
+ # after the radius token. Supported linestyles: solid, dotted, dashed, dashdot.
1851
+ circle_vals: List[Tuple[float, float, float, str | None, str | None]] = []
1852
+ _allowed_circle_styles = {"solid", "dotted", "dashed", "dashdot"}
1853
+ for c in lists.get("circle", []):
1854
+ raw = str(c).strip()
1855
+ # Expect something like: (expr_x, expr_y), radius_expr
1856
+ # We'll find first balanced tuple then split remaining by comma for radius.
1857
+ idx = raw.find("(")
1858
+ if idx == -1:
1859
+ continue
1860
+ # Grab balanced tuple
1861
+ depth = 0
1862
+ end_idx = -1
1863
+ for j in range(idx, len(raw)):
1864
+ if raw[j] == "(":
1865
+ depth += 1
1866
+ elif raw[j] == ")":
1867
+ depth -= 1
1868
+ if depth == 0:
1869
+ end_idx = j
1870
+ break
1871
+ if end_idx == -1:
1872
+ continue
1873
+ inner = raw[idx + 1 : end_idx]
1874
+ # split inner into x,y
1875
+ depth2 = 0
1876
+ comma_i = -1
1877
+ for k, ch in enumerate(inner):
1878
+ if ch == "(":
1879
+ depth2 += 1
1880
+ elif ch == ")":
1881
+ depth2 -= 1
1882
+ elif ch == "," and depth2 == 0:
1883
+ comma_i = k
1884
+ break
1885
+ if comma_i == -1:
1886
+ continue
1887
+ x_expr = inner[:comma_i].strip()
1888
+ y_expr = inner[comma_i + 1 :].strip()
1889
+ # Remaining after tuple for radius
1890
+ rest = raw[end_idx + 1 :].strip().lstrip(",").strip()
1891
+ if not rest:
1892
+ continue
1893
+ # Split rest into top-level comma tokens (radius + optional style/color)
1894
+ depth3 = 0
1895
+ tokens: List[str] = []
1896
+ cur: List[str] = []
1897
+ for ch in rest:
1898
+ if ch == "(":
1899
+ depth3 += 1
1900
+ cur.append(ch)
1901
+ elif ch == ")":
1902
+ depth3 -= 1
1903
+ cur.append(ch)
1904
+ elif ch == "," and depth3 == 0:
1905
+ part = "".join(cur).strip()
1906
+ if part:
1907
+ tokens.append(part)
1908
+ cur = []
1909
+ else:
1910
+ cur.append(ch)
1911
+ tail = "".join(cur).strip()
1912
+ if tail:
1913
+ tokens.append(tail)
1914
+ if not tokens:
1915
+ continue
1916
+ r_token = tokens[0]
1917
+ style_circle: str | None = None
1918
+ color_circle: str | None = None
1919
+ for tok in tokens[1:]:
1920
+ low = tok.lower()
1921
+ if low in _allowed_circle_styles and style_circle is None:
1922
+ style_circle = low
1923
+ elif color_circle is None:
1924
+ color_circle = tok
1925
+ try:
1926
+ xv = _eval_expr(x_expr)
1927
+ yv = _eval_expr(y_expr)
1928
+ rv = _eval_expr(r_token)
1929
+ if rv <= 0:
1930
+ continue
1931
+ circle_vals.append((float(xv), float(yv), float(rv), style_circle, color_circle))
1932
+ except Exception:
1933
+ # Silently skip invalid circle
1934
+ pass
1935
+
1936
+ # ellipses: (x0,y0), a, b[, linestyle][, color]
1937
+ # Parameterization: x = x0 + a*cos(t), y = y0 + b*sin(t), t in [0, 2*pi]
1938
+ ellipse_vals: List[Tuple[float, float, float, float, str | None, str | None]] = []
1939
+ _allowed_ellipse_styles = _allowed_circle_styles
1940
+ for e in lists.get("ellipse", []):
1941
+ raw = str(e).strip()
1942
+ idx = raw.find("(")
1943
+ if idx == -1:
1944
+ continue
1945
+ depth = 0
1946
+ end_idx = -1
1947
+ for j in range(idx, len(raw)):
1948
+ if raw[j] == "(":
1949
+ depth += 1
1950
+ elif raw[j] == ")":
1951
+ depth -= 1
1952
+ if depth == 0:
1953
+ end_idx = j
1954
+ break
1955
+ if end_idx == -1:
1956
+ continue
1957
+ inner = raw[idx + 1 : end_idx]
1958
+ # split inner center on top-level comma
1959
+ depth2 = 0
1960
+ comma_i = -1
1961
+ for k, ch in enumerate(inner):
1962
+ if ch == "(":
1963
+ depth2 += 1
1964
+ elif ch == ")":
1965
+ depth2 -= 1
1966
+ elif ch == "," and depth2 == 0:
1967
+ comma_i = k
1968
+ break
1969
+ if comma_i == -1:
1970
+ continue
1971
+ x0_expr = inner[:comma_i].strip()
1972
+ y0_expr = inner[comma_i + 1 :].strip()
1973
+ rest = raw[end_idx + 1 :].strip().lstrip(",").strip()
1974
+ if not rest:
1975
+ continue
1976
+ # tokenize rest top-level commas
1977
+ depth3 = 0
1978
+ tokens: List[str] = []
1979
+ cur: List[str] = []
1980
+ for ch in rest:
1981
+ if ch == "(":
1982
+ depth3 += 1
1983
+ cur.append(ch)
1984
+ elif ch == ")":
1985
+ depth3 -= 1
1986
+ cur.append(ch)
1987
+ elif ch == "," and depth3 == 0:
1988
+ part = "".join(cur).strip()
1989
+ if part:
1990
+ tokens.append(part)
1991
+ cur = []
1992
+ else:
1993
+ cur.append(ch)
1994
+ tail = "".join(cur).strip()
1995
+ if tail:
1996
+ tokens.append(tail)
1997
+ if len(tokens) < 2: # need a and b at least
1998
+ continue
1999
+ a_expr = tokens[0]
2000
+ b_expr = tokens[1]
2001
+ style_e: str | None = None
2002
+ color_e: str | None = None
2003
+ for tok in tokens[2:]:
2004
+ low = tok.lower()
2005
+ if low in _allowed_ellipse_styles and style_e is None:
2006
+ style_e = low
2007
+ elif color_e is None:
2008
+ color_e = tok
2009
+ try:
2010
+ x0v = _eval_expr(x0_expr)
2011
+ y0v = _eval_expr(y0_expr)
2012
+ av = _eval_expr(a_expr)
2013
+ bv = _eval_expr(b_expr)
2014
+ if av <= 0 or bv <= 0:
2015
+ continue
2016
+ ellipse_vals.append(
2017
+ (float(x0v), float(y0v), float(av), float(bv), style_e, color_e)
2018
+ )
2019
+ except Exception:
2020
+ pass
2021
+
2022
+ explicit_name = merged.get("name")
2023
+ debug_mode = "debug" in merged
2024
+ # curves: x_expr, y_expr, (t_start, t_end)[, linestyle][, color]
2025
+ curve_specs: List[Tuple[str, str, float, float, str | None, str | None]] = []
2026
+ _allowed_curve_styles = {"solid", "dotted", "dashed", "dashdot"}
2027
+ for c_line in lists.get("curve", []):
2028
+ s_line = str(c_line).strip()
2029
+ # Split top-level commas
2030
+ depth_c = 0
2031
+ parts_c: List[str] = []
2032
+ cur_c: List[str] = []
2033
+ for ch in s_line:
2034
+ if ch == "(":
2035
+ depth_c += 1
2036
+ cur_c.append(ch)
2037
+ elif ch == ")":
2038
+ depth_c -= 1
2039
+ cur_c.append(ch)
2040
+ elif ch == "," and depth_c == 0:
2041
+ token = "".join(cur_c).strip()
2042
+ if token:
2043
+ parts_c.append(token)
2044
+ cur_c = []
2045
+ else:
2046
+ cur_c.append(ch)
2047
+ tail_c = "".join(cur_c).strip()
2048
+ if tail_c:
2049
+ parts_c.append(tail_c)
2050
+ if len(parts_c) < 3:
2051
+ continue
2052
+ x_expr_c = parts_c[0]
2053
+ y_expr_c = parts_c[1]
2054
+ interval_token = parts_c[2]
2055
+ m_iv = re.match(r"^\(\s*(.+?)\s*,\s*(.+?)\s*\)$", interval_token)
2056
+ if not m_iv:
2057
+ continue
2058
+ t0_expr = m_iv.group(1)
2059
+ t1_expr = m_iv.group(2)
2060
+ style_cur: str | None = None
2061
+ color_cur: str | None = None
2062
+ for tok in parts_c[3:]:
2063
+ low = tok.lower()
2064
+ if low in _allowed_curve_styles and style_cur is None:
2065
+ style_cur = low
2066
+ elif color_cur is None:
2067
+ color_cur = tok
2068
+ try:
2069
+ t0_val = _eval_expr(t0_expr)
2070
+ t1_val = _eval_expr(t1_expr)
2071
+ if t1_val < t0_val:
2072
+ t0_val, t1_val = t1_val, t0_val
2073
+ curve_specs.append(
2074
+ (
2075
+ x_expr_c,
2076
+ y_expr_c,
2077
+ float(t0_val),
2078
+ float(t1_val),
2079
+ style_cur,
2080
+ color_cur,
2081
+ )
2082
+ )
2083
+ except Exception:
2084
+ continue
2085
+
2086
+ # Parse figsize early (string like (6,4) or [6,4]) but apply at end
2087
+ def _parse_figsize(val: Any):
2088
+ if not isinstance(val, str):
2089
+ return None
2090
+ s = val.strip()
2091
+ if not s:
2092
+ return None
2093
+ lit = _safe_literal(s)
2094
+ if isinstance(lit, (list, tuple)) and len(lit) >= 2:
2095
+ try:
2096
+ w = float(lit[0])
2097
+ h = float(lit[1])
2098
+ if w > 0 and h > 0:
2099
+ return (w, h)
2100
+ except Exception:
2101
+ return None
2102
+ # fallback simple regex (a,b)
2103
+ m = re.match(r"\(\s*([0-9]+(?:\.[0-9]+)?)\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)", s)
2104
+ if m:
2105
+ try:
2106
+ w = float(m.group(1))
2107
+ h = float(m.group(2))
2108
+ if w > 0 and h > 0:
2109
+ return (w, h)
2110
+ except Exception:
2111
+ return None
2112
+ return None
2113
+
2114
+ parsed_figsize = _parse_figsize(figsize_raw)
2115
+
2116
+ # Explicit LaTeX text rendering control. Default to True for consistent LaTeX fonts.
2117
+ # If the directive option is omitted, fall back to global config value 'plot_default_usetex' (defaults True).
2118
+ usetex_opt = _parse_bool(merged.get("usetex"), default=None)
2119
+ default_cfg = getattr(env.config, "plot_default_usetex", True)
2120
+ use_usetex = bool(usetex_opt) if usetex_opt is not None else bool(default_cfg)
2121
+
2122
+ # Hash includes all content affecting the image
2123
+ content_hash = _hash_key(
2124
+ "|".join(fn_exprs),
2125
+ "|".join(fn_labels_list),
2126
+ "|".join(["" if d is None else f"{d[0]},{d[1]}" for d in fn_domains_list]),
2127
+ "|".join([("|".join([str(x) for x in exs])) for exs in fn_exclusions_list]),
2128
+ ";".join([f"{x},{y}" for x, y in point_vals]),
2129
+ ";".join(
2130
+ [f"{xt[0]},{xt[1]}->{xy[0]},{xy[1]}:{t}:{arc}" for (xt, xy, t, arc) in ann_vals]
2131
+ ),
2132
+ ";".join(
2133
+ [
2134
+ (f"{x}" if y0 is None or y1 is None else f"{x},{y0},{y1}")
2135
+ + f":{st or ''}:{col or ''}"
2136
+ for (x, y0, y1, st, col) in vline_vals
2137
+ ]
2138
+ ),
2139
+ ";".join(
2140
+ [
2141
+ (f"{y}" if x0 is None or x1 is None else f"{y},{x0},{x1}")
2142
+ + f":{st or ''}:{col or ''}"
2143
+ for (y, x0, x1, st, col) in hline_vals
2144
+ ]
2145
+ ),
2146
+ ";".join(
2147
+ [
2148
+ f"{int(show)}:" + "|".join([f"{x},{y}" for (x, y) in pts])
2149
+ for (pts, show) in poly_vals
2150
+ ]
2151
+ ),
2152
+ ";".join(
2153
+ [
2154
+ (color or "")
2155
+ + ":"
2156
+ + ("" if alpha is None else str(alpha))
2157
+ + ":"
2158
+ + "|".join([f"{x},{y}" for (x, y) in pts])
2159
+ for (pts, color, alpha) in poly_fill_vals
2160
+ ]
2161
+ ),
2162
+ ";".join(
2163
+ [
2164
+ f"{p1[0]},{p1[1]}->{p2[0]},{p2[1]}:{(st or '')}:{(col or '')}"
2165
+ for (p1, p2, st, col) in line_segment_vals
2166
+ ]
2167
+ ),
2168
+ ";".join(
2169
+ [
2170
+ f"{cx},{cy}:{r}:{sa}:{ea}:{(st or '')}:{(col or '')}"
2171
+ for (cx, cy, r, sa, ea, st, col) in angle_arcs
2172
+ ]
2173
+ ),
2174
+ ";".join(
2175
+ [
2176
+ f"{xy[0]},{xy[1]}:{length}:{orientation}"
2177
+ for (xy, length, orientation) in bar_vals
2178
+ ]
2179
+ ),
2180
+ ";".join([f"{x},{y}:{dx},{dy}:{col}" for (x, y, dx, dy, col) in vector_vals]),
2181
+ "|".join(axis_cmds),
2182
+ ";".join([f"{a},{b}:{(st or '')}:{(col or '')}" for (a, b, st, col) in line_vals]),
2183
+ ";".join(
2184
+ [f"{a},{b}:{x0}:{(st or '')}:{(col or '')}" for (a, b, x0, st, col) in tangent_vals]
2185
+ ),
2186
+ ";".join(
2187
+ [
2188
+ f"{x},{y}:{txt}:{pos}:{int(1 if bbox else 0)}"
2189
+ for (x, y, txt, pos, bbox) in text_vals
2190
+ ]
2191
+ ),
2192
+ "|".join(fn_colors_list),
2193
+ xmin,
2194
+ xmax,
2195
+ ymin,
2196
+ ymax,
2197
+ xstep,
2198
+ ystep,
2199
+ fontsize,
2200
+ lw,
2201
+ alpha,
2202
+ str(merged.get("xlabel", "")),
2203
+ str(merged.get("ylabel", "")),
2204
+ int(bool(ticks_flag)),
2205
+ int(bool(grid_flag)),
2206
+ str(merged.get("xticks", "")),
2207
+ str(merged.get("yticks", "")),
2208
+ str(parsed_figsize),
2209
+ int(bool(use_usetex)),
2210
+ )
2211
+ base_name = explicit_name or f"plot_{content_hash}"
2212
+
2213
+ rel_dir = os.path.join("_static", "plot")
2214
+ abs_dir = os.path.join(app.srcdir, rel_dir)
2215
+ os.makedirs(abs_dir, exist_ok=True)
2216
+ svg_name = f"{base_name}.svg"
2217
+ abs_svg = os.path.join(abs_dir, svg_name)
2218
+
2219
+ regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
2220
+ if regenerate:
2221
+ import matplotlib
2222
+
2223
+ matplotlib.use("Agg")
2224
+ try:
2225
+ # Ensure consistent text rendering from drawing through save.
2226
+ _old_usetex = matplotlib.rcParams.get("text.usetex")
2227
+ _old_mathtext = matplotlib.rcParams.get("mathtext.fontset")
2228
+ try:
2229
+ matplotlib.rcParams["text.usetex"] = use_usetex
2230
+ # Prefer Computer Modern math text when not using external LaTeX
2231
+ if not use_usetex:
2232
+ matplotlib.rcParams["mathtext.fontset"] = "cm"
2233
+ except Exception:
2234
+ pass
2235
+ # Determine axis flags early
2236
+ axis_off = any(str(c).lower() == "off" for c in axis_cmds)
2237
+ axis_equal = any(str(c).lower() == "equal" for c in axis_cmds)
2238
+
2239
+ if axis_off:
2240
+ # Create a plain figure/axes (no plotmath.plot) so nothing (ticks/grid)
2241
+ # is drawn before we hide the axes.
2242
+ import matplotlib.pyplot as _plt
2243
+
2244
+ fig, ax = _plt.subplots()
2245
+ # Provide a reasonable default size similar to plotmath defaults
2246
+ fig.set_size_inches(6.0, 6.0)
2247
+ ax.set_xlim(xmin, xmax)
2248
+ ax.set_ylim(ymin, ymax)
2249
+ # Hide coordinate system
2250
+ try:
2251
+ ax.axis("off")
2252
+ except Exception:
2253
+ pass
2254
+ # Apply equal aspect if requested
2255
+ if axis_equal:
2256
+ try:
2257
+ ax.axis("equal")
2258
+ except Exception:
2259
+ pass
2260
+ else:
2261
+ # Standard path: delegate axis setup (ticks, grid, labels) to plotmath
2262
+ fig, ax = plotmath.plot(
2263
+ functions=[],
2264
+ fn_labels=False,
2265
+ xmin=xmin,
2266
+ xmax=xmax,
2267
+ ymin=ymin,
2268
+ ymax=ymax,
2269
+ xstep=xstep,
2270
+ ystep=ystep,
2271
+ ticks=ticks_flag,
2272
+ grid=grid_flag,
2273
+ lw=lw,
2274
+ alpha=alpha,
2275
+ fontsize=fontsize,
2276
+ )
2277
+ # If equal requested (without off), apply after plot creation
2278
+ if axis_equal:
2279
+ try:
2280
+ ax.axis("equal")
2281
+ except Exception:
2282
+ pass
2283
+
2284
+ # Plot requested functions directly on ax, with optional labels, per-function domains, and exclusions
2285
+ if functions:
2286
+ import numpy as np
2287
+
2288
+ any_label = False
2289
+ for f, lbl, dom, exs, col_fun in zip(
2290
+ functions,
2291
+ fn_labels_list,
2292
+ fn_domains_list,
2293
+ fn_exclusions_list,
2294
+ fn_colors_list,
2295
+ ):
2296
+ x0, x1 = dom if dom is not None else (xmin, xmax)
2297
+ N = int(2**12)
2298
+ x = np.linspace(x0, x1, N)
2299
+ y = f(x)
2300
+ # Ensure float array and blank out non-finite values
2301
+ y = np.asarray(y, dtype=float)
2302
+ y[~np.isfinite(y)] = np.nan
2303
+ # More robust exclusion handling: blank a window around each excluded x
2304
+ exs_in = [e for e in exs if x0 < e < x1]
2305
+ if exs_in and N > 1:
2306
+ dx = (x1 - x0) / (N - 1)
2307
+ # Window width larger than step to ensure a gap; include tiny absolute floor
2308
+ w = max(4 * dx, 1e-6 * (1.0 + max(abs(e) for e in exs_in)))
2309
+ for e in exs_in:
2310
+ try:
2311
+ mask = np.abs(x - e) <= w
2312
+ if mask.any():
2313
+ y[mask] = np.nan
2314
+ # Also blank the nearest index and a couple of neighbors to guarantee a break
2315
+ j = int(np.argmin(np.abs(x - e)))
2316
+ for k in (j - 2, j - 1, j, j + 1, j + 2):
2317
+ if 0 <= k < y.size:
2318
+ y[k] = np.nan
2319
+ except Exception:
2320
+ # Last resort: nearest index only
2321
+ try:
2322
+ j = int(np.argmin(np.abs(x - e)))
2323
+ if 0 <= j < y.size:
2324
+ y[j] = np.nan
2325
+ except Exception:
2326
+ pass
2327
+ # Additionally, break lines across steep jumps or extreme values
2328
+ # Determine a reasonable y-span for thresholds
2329
+ y_span = (
2330
+ abs(ymax - ymin) if (ymax is not None and ymin is not None) else np.nan
2331
+ )
2332
+ if not (isinstance(y_span, (int, float)) and y_span > 0):
2333
+ finite_y = y[np.isfinite(y)]
2334
+ if finite_y.size > 0:
2335
+ y_span = float(np.nanmax(finite_y) - np.nanmin(finite_y))
2336
+ if not (isinstance(y_span, (int, float)) and y_span > 0):
2337
+ y_span = 1.0
2338
+ # Break where adjacent points jump too much relative to span
2339
+ jump_factor = 0.5 # half the axis span signals discontinuity
2340
+ finite_pair = np.isfinite(y[:-1]) & np.isfinite(y[1:])
2341
+ big_jump = finite_pair & (np.abs(y[1:] - y[:-1]) > (jump_factor * y_span))
2342
+ if big_jump.any():
2343
+ idx_break = np.where(big_jump)[0]
2344
+ for i_b in idx_break:
2345
+ if 0 <= i_b + 1 < y.size:
2346
+ y[i_b + 1] = np.nan
2347
+ # Mask values far outside typical range to avoid vertical spikes drawing across
2348
+ mag_factor = 50.0
2349
+ too_big = np.isfinite(y) & (np.abs(y) > (mag_factor * y_span))
2350
+ if too_big.any():
2351
+ y[too_big] = np.nan
2352
+ # Resolve per-function color if provided
2353
+ _col_use = None
2354
+ if isinstance(col_fun, str) and col_fun.strip():
2355
+ try:
2356
+ _col_map = plotmath.COLORS.get(col_fun)
2357
+ except Exception:
2358
+ _col_map = None
2359
+ _col_use = _col_map if _col_map else col_fun
2360
+
2361
+ if lbl:
2362
+ any_label = True
2363
+ ax.plot(
2364
+ x,
2365
+ y,
2366
+ lw=lw,
2367
+ alpha=alpha,
2368
+ label=f"${lbl}$",
2369
+ **({"color": _col_use} if _col_use else {}),
2370
+ )
2371
+ else:
2372
+ ax.plot(
2373
+ x,
2374
+ y,
2375
+ lw=lw,
2376
+ alpha=alpha,
2377
+ **({"color": _col_use} if _col_use else {}),
2378
+ )
2379
+ if any_label:
2380
+ ax.legend(fontsize=int(fontsize))
2381
+
2382
+ # Annotations
2383
+ for xytext, xy, text, arc in ann_vals:
2384
+ plotmath.annotate(xy=xy, xytext=xytext, s=text, arc=arc, fontsize=int(fontsize))
2385
+
2386
+ # Lines (y = a*x + b) and tangents; draw before points so markers remain visible
2387
+ if line_vals or tangent_vals:
2388
+ import numpy as _np_l
2389
+
2390
+ style_map_line = {
2391
+ "solid": "-",
2392
+ "dotted": ":",
2393
+ "dashed": "--",
2394
+ "dashdot": "-.",
2395
+ }
2396
+ default_color_line = plotmath.COLORS.get("red")
2397
+ try:
2398
+ from matplotlib import colors as _mcolors
2399
+ except Exception:
2400
+ _mcolors = None
2401
+ x_line = _np_l.array([xmin, xmax], dtype=float)
2402
+
2403
+ def _draw_line(a_l: float, b_l: float, st_l: str | None, col_l: str | None):
2404
+ y_line = a_l * x_line + b_l
2405
+ ls = style_map_line.get((st_l or "dashed").lower(), "--")
2406
+ if col_l:
2407
+ _mapped = plotmath.COLORS.get(col_l)
2408
+ else:
2409
+ _mapped = None
2410
+ col_use = (_mapped if _mapped else col_l) or default_color_line
2411
+ if _mcolors is not None:
2412
+ try:
2413
+ _ = _mcolors.to_rgba(col_use)
2414
+ except Exception:
2415
+ col_use = default_color_line
2416
+ try:
2417
+ ax.plot(
2418
+ x_line,
2419
+ y_line,
2420
+ linestyle=ls,
2421
+ color=col_use,
2422
+ lw=lw,
2423
+ alpha=alpha,
2424
+ )
2425
+ except Exception:
2426
+ ax.plot(
2427
+ x_line,
2428
+ y_line,
2429
+ linestyle=ls,
2430
+ color=default_color_line,
2431
+ lw=lw,
2432
+ alpha=alpha,
2433
+ )
2434
+
2435
+ for a_l, b_l, st_l, col_l in line_vals:
2436
+ _draw_line(a_l, b_l, st_l, col_l)
2437
+
2438
+ # Tangents: allow optional style/color, with dashed orange default
2439
+ for a_t, b_t, _x0, st_t, col_t in tangent_vals:
2440
+ style_use = st_t or "dashed"
2441
+ color_use = col_t or "orange"
2442
+ _draw_line(a_t, b_t, style_use, color_use)
2443
+
2444
+ # Bars
2445
+ for xy, length, orientation in bar_vals:
2446
+ try:
2447
+ # Prefer plotmath.make_bar if available
2448
+ if hasattr(plotmath, "make_bar"):
2449
+ plotmath.make_bar(xy, length, orientation)
2450
+ else:
2451
+ # Fallback: use annotate directly on this axes
2452
+ x, y = xy
2453
+ if orientation == "horizontal":
2454
+ ax.annotate(
2455
+ "",
2456
+ xy=xy,
2457
+ xycoords="data",
2458
+ xytext=(x + length, y),
2459
+ textcoords="data",
2460
+ arrowprops=dict(
2461
+ arrowstyle="|-|,widthA=0.5,widthB=0.5",
2462
+ color="black",
2463
+ ),
2464
+ )
2465
+ else:
2466
+ ax.annotate(
2467
+ "",
2468
+ xy=xy,
2469
+ xycoords="data",
2470
+ xytext=(x, y + length),
2471
+ textcoords="data",
2472
+ arrowprops=dict(
2473
+ arrowstyle="|-|,widthA=0.5,widthB=0.5",
2474
+ color="black",
2475
+ ),
2476
+ )
2477
+ except Exception:
2478
+ pass
2479
+
2480
+ # Angle arcs
2481
+ if "angle_arcs" in locals() and angle_arcs:
2482
+ try:
2483
+ import numpy as _np_ang
2484
+ except Exception:
2485
+ _np_ang = None
2486
+ if _np_ang is not None:
2487
+ style_map_arc = {
2488
+ "solid": "-",
2489
+ "dotted": ":",
2490
+ "dashed": "--",
2491
+ "dashdot": "-.",
2492
+ }
2493
+ default_arc_color = plotmath.COLORS.get("black") or "black"
2494
+ for cx, cy, r, sa_deg, ea_deg, st_a, col_a in angle_arcs:
2495
+ try:
2496
+ sa = _np_ang.deg2rad(sa_deg)
2497
+ ea = _np_ang.deg2rad(ea_deg)
2498
+ theta = _np_ang.linspace(sa, ea, 1024)
2499
+ xs = cx + r * _np_ang.cos(theta)
2500
+ ys = cy + r * _np_ang.sin(theta)
2501
+ ls_use = style_map_arc.get((st_a or "solid").lower(), "-")
2502
+ # Resolve color via plotmath palette
2503
+ if col_a:
2504
+ _mapped = plotmath.COLORS.get(col_a)
2505
+ else:
2506
+ _mapped = None
2507
+ col_use = (_mapped if _mapped else col_a) or default_arc_color
2508
+ ax.plot(xs, ys, lw=1, color=col_use, linestyle=ls_use)
2509
+ except Exception:
2510
+ pass
2511
+
2512
+ # text with optional positioning and optional bbox
2513
+
2514
+ xmin, xmax = ax.get_xlim()
2515
+ ymin, ymax = ax.get_ylim()
2516
+ ax_dx = xmax - xmin
2517
+ ax_dy = ymax - ymin
2518
+
2519
+ # Determine axes pixel size for consistent visual offsets
2520
+ try:
2521
+ fig.canvas.draw() # ensure layout is realized
2522
+ _bbox_px = ax.get_window_extent()
2523
+ _ax_w_px, _ax_h_px = _bbox_px.width, _bbox_px.height
2524
+ if _ax_w_px <= 0 or _ax_h_px <= 0:
2525
+ _ax_w_px = _ax_h_px = None
2526
+ except Exception:
2527
+ _ax_w_px = _ax_h_px = None
2528
+
2529
+ for x0, y0, text, pos, use_bbox in text_vals:
2530
+ va, ha = _parse_text_positioning(pos)
2531
+ # Factors as fractions of axes size; keep long* ~3.3x larger
2532
+ _fx_short = 0.015
2533
+ _fy_short = 0.015
2534
+ _fx_long = 0.03
2535
+ _fy_long = 0.03
2536
+
2537
+ # Resolve long* into base alignment while keeping larger factors
2538
+ _use_fx = _fx_short
2539
+ _use_fy = _fy_short
2540
+ if va == "longbottom":
2541
+ va = "bottom"
2542
+ _use_fy = _fy_long
2543
+ elif va == "longtop":
2544
+ va = "top"
2545
+ _use_fy = _fy_long
2546
+ if ha == "longright":
2547
+ ha = "right"
2548
+ _use_fx = _fx_long
2549
+ elif ha == "longleft":
2550
+ ha = "left"
2551
+ _use_fx = _fx_long
2552
+
2553
+ if _ax_w_px and _ax_h_px:
2554
+ # Pixel-based offsets converted back to data units
2555
+ dx_px = 0.0
2556
+ dy_px = 0.0
2557
+ if ha == "right":
2558
+ dx_px = -_ax_w_px * _use_fx
2559
+ elif ha == "left":
2560
+ dx_px = _ax_w_px * _use_fx
2561
+ if va == "bottom":
2562
+ dy_px = _ax_h_px * _use_fy
2563
+ elif va == "top":
2564
+ dy_px = -_ax_h_px * _use_fy
2565
+ x_disp, y_disp = ax.transData.transform((x0, y0))
2566
+ x1, y1 = ax.transData.inverted().transform((x_disp + dx_px, y_disp + dy_px))
2567
+ dx = x1 - x0
2568
+ dy = y1 - y0
2569
+ else:
2570
+ # Fallback to fractions of data span
2571
+ if va == "bottom":
2572
+ dy = _fy_short * ax_dy if _use_fy == _fy_short else _fy_long * ax_dy
2573
+ elif va == "top":
2574
+ dy = -(_fy_short * ax_dy if _use_fy == _fy_short else _fy_long * ax_dy)
2575
+ else:
2576
+ dy = 0.0
2577
+ if ha == "right":
2578
+ dx = -(_fx_short * ax_dx if _use_fx == _fx_short else _fx_long * ax_dx)
2579
+ elif ha == "left":
2580
+ dx = _fx_short * ax_dx if _use_fx == _fx_short else _fx_long * ax_dx
2581
+ else:
2582
+ dx = 0.0
2583
+
2584
+ bbox_kwargs = (
2585
+ dict(
2586
+ boxstyle="round,pad=0.4",
2587
+ fc="white",
2588
+ ec="black",
2589
+ lw=1.5,
2590
+ alpha=0.7,
2591
+ )
2592
+ if use_bbox
2593
+ else None
2594
+ )
2595
+
2596
+ if bbox_kwargs:
2597
+ ax.text(
2598
+ x0 + 1.5 * dx,
2599
+ y0 + 1.5 * dy,
2600
+ text,
2601
+ fontsize=int(fontsize),
2602
+ ha=ha,
2603
+ va=va,
2604
+ bbox=bbox_kwargs,
2605
+ )
2606
+ else:
2607
+ ax.text(x0 + dx, y0 + dy, text, fontsize=int(fontsize), ha=ha, va=va)
2608
+
2609
+ # line segments (draw before vlines/hlines so guides overlay if needed)
2610
+ if "line_segment_vals" in locals() and line_segment_vals:
2611
+ style_map_seg = {
2612
+ "solid": "-",
2613
+ "dotted": ":",
2614
+ "dashed": "--",
2615
+ "dashdot": "-.",
2616
+ }
2617
+ default_seg_color = plotmath.COLORS.get("red")
2618
+ try:
2619
+ from matplotlib import colors as _mcolors_seg
2620
+ except Exception:
2621
+ _mcolors_seg = None
2622
+ for p1, p2, st_seg, col_seg in line_segment_vals:
2623
+ (x1s, y1s), (x2s, y2s) = p1, p2
2624
+ ls_use = style_map_seg.get((st_seg or "solid").lower(), "-")
2625
+ if col_seg:
2626
+ _mapped_seg = plotmath.COLORS.get(col_seg)
2627
+ else:
2628
+ _mapped_seg = None
2629
+ col_use = (_mapped_seg if _mapped_seg else col_seg) or default_seg_color
2630
+ if _mcolors_seg is not None:
2631
+ try:
2632
+ _ = _mcolors_seg.to_rgba(col_use)
2633
+ except Exception:
2634
+ col_use = default_seg_color
2635
+ try:
2636
+ ax.plot(
2637
+ [x1s, x2s],
2638
+ [y1s, y2s],
2639
+ linestyle=ls_use,
2640
+ color=col_use,
2641
+ lw=lw,
2642
+ )
2643
+ except Exception:
2644
+ pass
2645
+ # Circles
2646
+ if "circle_vals" in locals() and circle_vals:
2647
+ try:
2648
+ from matplotlib import patches as _mpatches_c
2649
+ except Exception:
2650
+ _mpatches_c = None
2651
+ if _mpatches_c is not None:
2652
+ style_map_circle = {
2653
+ "solid": "-",
2654
+ "dotted": ":",
2655
+ "dashed": "--",
2656
+ "dashdot": "-.",
2657
+ }
2658
+ default_circle_color = plotmath.COLORS.get("black") or "black"
2659
+ for cx, cy, r_c, st_c, col_c in circle_vals:
2660
+ try:
2661
+ # Resolve color
2662
+ if col_c:
2663
+ mapped = plotmath.COLORS.get(col_c)
2664
+ else:
2665
+ mapped = None
2666
+ col_use = (mapped if mapped else col_c) or default_circle_color
2667
+ # Resolve linestyle -> we pass as linestyle on patch edge
2668
+ ls_use = style_map_circle.get((st_c or "solid").lower(), "-")
2669
+ circ = _mpatches_c.Circle(
2670
+ (cx, cy),
2671
+ r_c,
2672
+ fill=False,
2673
+ edgecolor=col_use,
2674
+ facecolor="none",
2675
+ linestyle=ls_use,
2676
+ lw=lw,
2677
+ )
2678
+ ax.add_patch(circ)
2679
+ except Exception:
2680
+ pass
2681
+ # Ellipses
2682
+ if "ellipse_vals" in locals() and ellipse_vals:
2683
+ try:
2684
+ import numpy as _np_el
2685
+ except Exception:
2686
+ _np_el = None
2687
+ if _np_el is not None:
2688
+ style_map_ellipse = {
2689
+ "solid": "-",
2690
+ "dotted": ":",
2691
+ "dashed": "--",
2692
+ "dashdot": "-.",
2693
+ }
2694
+ default_ellipse_color = plotmath.COLORS.get("black") or "black"
2695
+ for x0e, y0e, a_e, b_e, st_e, col_e in ellipse_vals:
2696
+ try:
2697
+ t = _np_el.linspace(0, 2 * _np_el.pi, 1024)
2698
+ xs = x0e + a_e * _np_el.cos(t)
2699
+ ys = y0e + b_e * _np_el.sin(t)
2700
+ if col_e:
2701
+ mapped = plotmath.COLORS.get(col_e)
2702
+ else:
2703
+ mapped = None
2704
+ col_use = (mapped if mapped else col_e) or default_ellipse_color
2705
+ ls_use = style_map_ellipse.get((st_e or "solid").lower(), "-")
2706
+ ax.plot(xs, ys, color=col_use, linestyle=ls_use, lw=lw)
2707
+ except Exception:
2708
+ pass
2709
+
2710
+ # Curves (parametric x(t), y(t))
2711
+ if "curve_specs" in locals() and curve_specs:
2712
+ try:
2713
+ import sympy as _sp_curve
2714
+ import numpy as _np_curve
2715
+ except Exception:
2716
+ _sp_curve = None
2717
+ _np_curve = None
2718
+ if _sp_curve is not None and _np_curve is not None:
2719
+ style_map_curve = {
2720
+ "solid": "-",
2721
+ "dotted": ":",
2722
+ "dashed": "--",
2723
+ "dashdot": "-.",
2724
+ }
2725
+ default_curve_color = plotmath.COLORS.get("black") or "black"
2726
+ for x_expr_s, y_expr_s, t0_c, t1_c, st_c, col_c in curve_specs:
2727
+ try:
2728
+ t_sym = _sp_curve.symbols("t")
2729
+ # Sympify with local symbol t; rely on SymPy's safe parsing (no arbitrary exec)
2730
+ x_sym = _sp_curve.sympify(x_expr_s, locals={"t": t_sym})
2731
+ y_sym = _sp_curve.sympify(y_expr_s, locals={"t": t_sym})
2732
+ fx = _sp_curve.lambdify(t_sym, x_sym, "numpy")
2733
+ fy = _sp_curve.lambdify(t_sym, y_sym, "numpy")
2734
+ t_arr = _np_curve.linspace(t0_c, t1_c, 1024)
2735
+ xs = fx(t_arr)
2736
+ ys = fy(t_arr)
2737
+ # Basic sanity checks
2738
+ try:
2739
+ _ = len(xs)
2740
+ _ = len(ys)
2741
+ except Exception:
2742
+ continue
2743
+ mapped = plotmath.COLORS.get(col_c) if col_c else None
2744
+ col_use = (mapped if mapped else col_c) or default_curve_color
2745
+ ls_use = style_map_curve.get((st_c or "solid").lower(), "-")
2746
+ ax.plot(xs, ys, color=col_use, linestyle=ls_use, lw=lw)
2747
+ except Exception:
2748
+ continue
2749
+
2750
+ # vlines
2751
+ style_map = {
2752
+ "solid": "-",
2753
+ "dotted": ":",
2754
+ "dashed": "--",
2755
+ "dashdot": "-.",
2756
+ }
2757
+ default_color = plotmath.COLORS.get("red")
2758
+ for x_v, y0, y1, st, col in vline_vals:
2759
+ y_min = ymin if y0 is None else y0
2760
+ y_max = ymax if y1 is None else y1
2761
+ ls_val = style_map.get((st or "dashed").lower(), ":")
2762
+ # Resolve user color through plotmath.COLORS, then fallback to original, then default
2763
+ _mapped = plotmath.COLORS.get(col) if col else None
2764
+ color_to_try = (_mapped if _mapped else col) or default_color
2765
+ try:
2766
+ ax.vlines(
2767
+ x=x_v,
2768
+ ymin=y_min,
2769
+ ymax=y_max,
2770
+ colors=color_to_try,
2771
+ lw=lw,
2772
+ alpha=1,
2773
+ ls=ls_val,
2774
+ )
2775
+ except Exception:
2776
+ ax.vlines(
2777
+ x=x_v,
2778
+ ymin=y_min,
2779
+ ymax=y_max,
2780
+ colors=default_color,
2781
+ lw=lw,
2782
+ alpha=1,
2783
+ ls=ls_val,
2784
+ )
2785
+
2786
+ # hlines
2787
+ for y_h, x0, x1, st_h, col_h in hline_vals:
2788
+ x_min = xmin if x0 is None else x0
2789
+ x_max = xmax if x1 is None else x1
2790
+ ls_val_h = style_map.get((st_h or "dashed").lower(), ":")
2791
+ # Resolve user color through plotmath.COLORS, then fallback to original, then default
2792
+ _mapped_h = plotmath.COLORS.get(col_h) if col_h else None
2793
+ color_to_try_h = (_mapped_h if _mapped_h else col_h) or default_color
2794
+ try:
2795
+ ax.hlines(
2796
+ y=y_h,
2797
+ xmin=x_min,
2798
+ xmax=x_max,
2799
+ colors=color_to_try_h,
2800
+ lw=lw,
2801
+ alpha=1,
2802
+ ls=ls_val_h,
2803
+ )
2804
+ except Exception:
2805
+ ax.hlines(
2806
+ y=y_h,
2807
+ xmin=x_min,
2808
+ xmax=x_max,
2809
+ colors=default_color,
2810
+ lw=lw,
2811
+ alpha=1,
2812
+ ls=ls_val_h,
2813
+ )
2814
+
2815
+ # polygons
2816
+ for pts, show in poly_vals:
2817
+ kwargs = {"show_vertices": True} if show else {}
2818
+ try:
2819
+ plotmath.polygon(*pts, **kwargs)
2820
+ except Exception:
2821
+ # ignore to avoid breaking the build on a single bad polygon
2822
+ pass
2823
+
2824
+ # filled polygons
2825
+ default_fill_color = plotmath.COLORS.get("blue")
2826
+ for pts, color_fp, alpha_fp in poly_fill_vals:
2827
+ # Resolve user color through plotmath.COLORS, then fallback to original, then default
2828
+ if color_fp:
2829
+ _mapped_fp = plotmath.COLORS.get(color_fp)
2830
+ else:
2831
+ _mapped_fp = None
2832
+ c = (_mapped_fp if _mapped_fp else color_fp) or default_fill_color
2833
+ a = 0.1 if alpha_fp is None else alpha_fp
2834
+ try:
2835
+ plotmath.polygon(*pts, edges=False, color=c, alpha=a)
2836
+ except Exception:
2837
+ try:
2838
+ plotmath.polygon(*pts, edges=False, facecolor=c, alpha=a)
2839
+ except Exception:
2840
+ plotmath.polygon(*pts, edges=False, alpha=a)
2841
+
2842
+ # Vectors (quiver) drawn before points so markers overlay arrow heads
2843
+ if vector_vals:
2844
+ default_vector_color = plotmath.COLORS.get("black") or "black"
2845
+ try:
2846
+ for x_v, y_v, dx_v, dy_v, col_v in vector_vals:
2847
+ # Resolve color through palette first
2848
+ if col_v:
2849
+ _mapped_vec = plotmath.COLORS.get(col_v)
2850
+ else:
2851
+ _mapped_vec = None
2852
+ color_use = (
2853
+ _mapped_vec if _mapped_vec else col_v
2854
+ ) or default_vector_color
2855
+ ax.quiver(
2856
+ x_v,
2857
+ y_v,
2858
+ dx_v,
2859
+ dy_v,
2860
+ angles="xy",
2861
+ scale_units="xy",
2862
+ scale=1,
2863
+ width=0.0065,
2864
+ headwidth=4,
2865
+ headlength=4.5,
2866
+ color=color_use,
2867
+ )
2868
+ except Exception:
2869
+ pass
2870
+
2871
+ # Plot points
2872
+ for x0, y0 in point_vals:
2873
+ ax.plot(x0, y0, "o", markersize=10, alpha=0.8, color="black")
2874
+
2875
+ # axis commands (run sequentially) — retain for legacy commands; we
2876
+ # skip reapplying 'off'/'equal' earlier logic is already applied but
2877
+ # they are harmless if repeated.
2878
+ for cmd in axis_cmds:
2879
+ try:
2880
+ ax.axis(cmd)
2881
+ except Exception:
2882
+ pass
2883
+
2884
+ # Axis labels: allow optional labelpad via "label, pad"
2885
+ def _split_label_and_pad(val: Any) -> tuple[str | None, float | None]:
2886
+ if not isinstance(val, str):
2887
+ return None, None
2888
+ s = val.strip()
2889
+ if not s:
2890
+ return None, None
2891
+ # Try literal form [label, pad] or (label, pad)
2892
+ lit = _safe_literal(s)
2893
+ if isinstance(lit, (list, tuple)) and len(lit) >= 1:
2894
+ label = str(lit[0]).strip()
2895
+ pad: float | None = None
2896
+ if len(lit) >= 2:
2897
+ try:
2898
+ pad = float(lit[1])
2899
+ except Exception:
2900
+ pad = None
2901
+ return (label if label else None), pad
2902
+ # CSV fallback: split on last comma so labels with commas still work when quoted
2903
+ parts = [p.strip() for p in s.split(",")]
2904
+ if len(parts) >= 2:
2905
+ try:
2906
+ pad = float(parts[-1])
2907
+ label = ",".join(parts[:-1]).strip()
2908
+ return (label if label else None), pad
2909
+ except Exception:
2910
+ pass
2911
+ return (s if s else None), None
2912
+
2913
+ xl_raw = merged.get("xlabel")
2914
+ yl_raw = merged.get("ylabel")
2915
+ xl_text, xl_pad = _split_label_and_pad(xl_raw)
2916
+ yl_text, yl_pad = _split_label_and_pad(yl_raw)
2917
+
2918
+ if isinstance(yl_text, str) and yl_text.strip():
2919
+ try:
2920
+ kwargs = dict(fontsize=int(fontsize), loc="top", rotation="horizontal")
2921
+ if yl_pad is not None:
2922
+ kwargs["labelpad"] = yl_pad
2923
+ ax.set_ylabel(yl_text, **kwargs)
2924
+ except Exception:
2925
+ ax.set_ylabel(yl_text, fontsize=int(fontsize))
2926
+ if isinstance(xl_text, str) and xl_text.strip():
2927
+ try:
2928
+ kwargs = dict(fontsize=int(fontsize), loc="right")
2929
+ if xl_pad is not None:
2930
+ kwargs["labelpad"] = xl_pad
2931
+ ax.set_xlabel(xl_text, **kwargs)
2932
+ except Exception:
2933
+ ax.set_xlabel(xl_text, fontsize=int(fontsize))
2934
+
2935
+ # Apply user figsize at the very end if provided
2936
+ if parsed_figsize is not None:
2937
+ try:
2938
+ fig.set_size_inches(*parsed_figsize)
2939
+ except Exception:
2940
+ pass
2941
+
2942
+ # Handle individual tick control (xticks/yticks off)
2943
+ xticks_raw = merged.get("xticks")
2944
+ yticks_raw = merged.get("yticks")
2945
+
2946
+ if isinstance(xticks_raw, str) and xticks_raw.strip().lower() == "off":
2947
+ try:
2948
+ ax.set_xticks([])
2949
+ except Exception:
2950
+ pass
2951
+
2952
+ if isinstance(yticks_raw, str) and yticks_raw.strip().lower() == "off":
2953
+ try:
2954
+ ax.set_yticks([])
2955
+ except Exception:
2956
+ pass
2957
+
2958
+ # Apply tight_layout to prevent label clipping
2959
+ try:
2960
+ # Make sure text extents are realized before layout when using TeX
2961
+ try:
2962
+ fig.canvas.draw()
2963
+ except Exception:
2964
+ pass
2965
+ fig.tight_layout()
2966
+ except Exception:
2967
+ pass
2968
+
2969
+ fig.savefig(
2970
+ abs_svg,
2971
+ format="svg",
2972
+ transparent=True,
2973
+ )
2974
+ if debug_mode:
2975
+ # Sidecar PDF (optional for debugging)
2976
+ try:
2977
+ fig.savefig(
2978
+ os.path.join(abs_dir, f"{base_name}.pdf"),
2979
+ format="pdf",
2980
+ transparent=True,
2981
+ )
2982
+ except Exception:
2983
+ pass
2984
+
2985
+ matplotlib.pyplot.close(fig)
2986
+ # Restore rcParams modified for this figure
2987
+ try:
2988
+ matplotlib.rcParams["text.usetex"] = _old_usetex
2989
+ matplotlib.rcParams["mathtext.fontset"] = _old_mathtext
2990
+ except Exception:
2991
+ pass
2992
+ except Exception as e:
2993
+ # Best-effort rcParams restoration on error
2994
+ try:
2995
+ matplotlib.rcParams["text.usetex"] = _old_usetex
2996
+ matplotlib.rcParams["mathtext.fontset"] = _old_mathtext
2997
+ except Exception:
2998
+ pass
2999
+ return [
3000
+ self.state_machine.reporter.error(
3001
+ f"Feil under generering av figur: {e}", line=self.lineno
3002
+ )
3003
+ ]
3004
+
3005
+ if not os.path.exists(abs_svg):
3006
+ return [self.state_machine.reporter.error("plot: SVG mangler.", line=self.lineno)]
3007
+
3008
+ env.note_dependency(abs_svg)
3009
+ # copy into build _static
3010
+ try:
3011
+ out_static = os.path.join(app.outdir, "_static", "plot")
3012
+ os.makedirs(out_static, exist_ok=True)
3013
+ shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
3014
+ except Exception:
3015
+ pass
3016
+
3017
+ try:
3018
+ raw_svg = open(abs_svg, "r", encoding="utf-8").read()
3019
+ except Exception as e:
3020
+ return [
3021
+ self.state_machine.reporter.error(
3022
+ f"plot inline: kunne ikke lese SVG: {e}", line=self.lineno
3023
+ )
3024
+ ]
3025
+
3026
+ if not debug_mode and "viewBox" in raw_svg:
3027
+ raw_svg = _strip_root_svg_size(raw_svg)
3028
+
3029
+ if not debug_mode:
3030
+ raw_svg = _rewrite_ids(raw_svg, f"cpl_{content_hash}_{uuid.uuid4().hex[:6]}_")
3031
+
3032
+ alt_default = "Tilpasset figur"
3033
+ alt = merged.get("alt", alt_default)
3034
+
3035
+ width_opt = merged.get("width")
3036
+ percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
3037
+
3038
+ def _augment(m):
3039
+ tag = m.group(0)
3040
+ if "class=" not in tag:
3041
+ tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
3042
+ else:
3043
+ tag = tag.replace('class="', 'class="graph-inline-svg ')
3044
+ if alt and "aria-label=" not in tag:
3045
+ tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
3046
+ if width_opt:
3047
+ if percent:
3048
+ wval = width_opt.strip()
3049
+ else:
3050
+ wval = width_opt.strip()
3051
+ if wval.isdigit():
3052
+ wval += "px"
3053
+ style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
3054
+ if "style=" in tag:
3055
+ tag = re.sub(
3056
+ r'style="([^"]*)"',
3057
+ lambda mm: f'style="{mm.group(1)}; {style_frag}"',
3058
+ tag,
3059
+ count=1,
3060
+ )
3061
+ else:
3062
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
3063
+ return tag
3064
+
3065
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
3066
+ # Deliberately do not inject a <title> element: browsers display it as a tooltip
3067
+ # on hover which is distracting for readers. Accessibility is still ensured via
3068
+ # role="img" and aria-label attributes already added in _augment(). If a title
3069
+ # is ever desired for a specific figure, that can be added manually after build
3070
+ # or a future directive option could re-enable this behavior.
3071
+
3072
+ figure = nodes.figure()
3073
+ figure.setdefault("classes", []).extend(["adaptive-figure", "plot-figure", "no-click"])
3074
+ raw_node = nodes.raw("", raw_svg, format="html")
3075
+ raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
3076
+ figure += raw_node
3077
+
3078
+ extra_classes = merged.get("class")
3079
+ if extra_classes:
3080
+ figure["classes"].extend(extra_classes)
3081
+ figure["align"] = merged.get("align", "center")
3082
+
3083
+ caption_lines = list(self.content)[caption_idx:]
3084
+ while caption_lines and not caption_lines[0].strip():
3085
+ caption_lines.pop(0)
3086
+ if caption_lines:
3087
+ caption = nodes.caption()
3088
+ caption += nodes.Text("\n".join(caption_lines))
3089
+ figure += caption
3090
+
3091
+ if explicit_name:
3092
+ self.add_name(figure)
3093
+ return [figure]
3094
+
3095
+
3096
+ def setup(app): # pragma: no cover
3097
+ app.add_directive("plot", PlotDirective)
3098
+ # Ensure our figure CSS is linked even if the root package setup did not run first
3099
+ try:
3100
+ app.add_css_file("munchboka/css/general_style.css")
3101
+ except Exception:
3102
+ pass
3103
+ # Global default for LaTeX usage in plots; can be overridden per-figure via 'usetex:'
3104
+ app.add_config_value("plot_default_usetex", True, "env")
3105
+ return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}