munchboka-edutools 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

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