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