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,2524 @@
1
+ """multi-plot directive
2
+ =================================
3
+
4
+ Create a responsive grid (rows × cols) of mathematical function plots using
5
+ ``plotmath.multiplot`` and a collection of numpy / matplotlib transformations.
6
+ The directive focuses on pedagogical clarity, robust domain / exclusion handling,
7
+ and clean SVG output (sanitized IDs, no fixed width/height, optional width styling).
8
+
9
+ Quick Example (MyST)
10
+ --------------------
11
+ :::{multi-plot}
12
+ functions: [x**2 - 2*x, -x + 2, x - 3]
13
+ rows: 1
14
+ cols: 3
15
+ width: 100%
16
+ alt: Tre grafer som sammenligner funksjoner
17
+ :::
18
+
19
+ Minimal Example
20
+ ---------------
21
+ :::{multi-plot}
22
+ functions: [x**2, sin(x), exp(x)]
23
+ :::
24
+
25
+ Front‑Matter vs Inline Options
26
+ ------------------------------
27
+ You can either:
28
+
29
+ 1. Supply key/value pairs directly at the top (each ``key: value`` line) until a
30
+ blank or non-matching line is encountered.
31
+ 2. Use a fenced *YAML-like* block delimited by ``---`` lines, then an empty line,
32
+ followed by an optional caption.
33
+
34
+ Example with front‑matter + caption:
35
+ :::{multi-plot}
36
+ ---
37
+ functions: [x**2, -x, x**3]
38
+ rows: 1
39
+ cols: 3
40
+ width: 80%
41
+ alt: Tre forskjellige funksjoner
42
+ ---
43
+ Sammenligning av tre funksjoner.
44
+ :::
45
+
46
+ Caching & Regeneration
47
+ ----------------------
48
+ All parameters (functions, domains, lines, style flags, etc.) are hashed to form
49
+ an SVG cache filename under ``_static/multi_plot``. Use ``:nocache:`` (or
50
+ ``nocache:`` in front‑matter) to force regeneration. The hash includes axis
51
+ limits, domain exclusions, per-axis lines, and labeling flags to minimize stale
52
+ outputs.
53
+
54
+ Safety & ID Rewriting
55
+ ---------------------
56
+ All non-font glyph IDs within the generated SVG are rewritten with a unique
57
+ prefix to avoid collisions when embedding multiple multi-plot figures on the
58
+ same page. URL and href references (including gradients, clips, etc.) are
59
+ updated accordingly.
60
+
61
+ Accessibility
62
+ -------------
63
+ The root ``<svg>`` receives ``role="img"`` and ``aria-label="..."`` (from the
64
+ ``alt`` option or a generated default) instead of a ``<title>`` tag to avert
65
+ hover tooltips while retaining screen reader context.
66
+
67
+ Width & Layout
68
+ --------------
69
+ ``width`` may be a percentage (e.g. ``60%``, ``100%``) or an absolute size
70
+ (``400`` or ``400px``). Percent widths get ``display:block; margin:0 auto;`` for
71
+ centering. If you need left/right alignment, supply ``align: left`` or
72
+ ``align: right`` (the directive's figure container alignment plus CSS handles the rest).
73
+
74
+ Function Expressions
75
+ --------------------
76
+ Provide a comma-separated or bracketed list (``[ ... ]``). Each expression is
77
+ sympified via SymPy, then vectorized with ``lambdify``. Example accepted forms:
78
+ * ``x**2 - 2*x``
79
+ * ``sin(x)``
80
+ * ``exp(x)``
81
+
82
+ Labels
83
+ ------
84
+ Use either ``fn_labels`` or its alias ``function-names``. If the number of
85
+ labels matches the number of functions, they are shown (LaTeX math wrapped in
86
+ ``$...$``). Otherwise, labels are auto-inferred / hidden depending on plotmath
87
+ defaults. Example:
88
+ ``fn_labels: [f(x)=x^2, g(x)=-x, h(x)=x^3]``
89
+
90
+ Per-Function Domains & Exclusions
91
+ ---------------------------------
92
+ ``domains`` accepts top-level comma-separated domain specs with optional
93
+ exclusion points using a set difference notation ``(a,b) \ {x1, x2, ...}``.
94
+ Example:
95
+ ``domains: [( -5,5 ) \ {0}, ( -2,2 ), (0, 6) \ {1,2}]``
96
+
97
+ Excluded x-values are widened internally (with a small numeric halo + neighbor
98
+ NaNs) to create visible breaks instead of spuriously connecting across
99
+ discontinuities.
100
+
101
+ Vertical & Horizontal Lines
102
+ ---------------------------
103
+ ``vlines`` / ``hlines`` accept lists of x or y locations per function panel. Each
104
+ panel’s list is expressed as a top-level comma-separated group. Example:
105
+ ``vlines: [[-2,0,2], None, [1]]`` or ``vlines: [-2; None; 1]`` (semicolons treated as commas).
106
+
107
+ Axis-Specific Limits
108
+ --------------------
109
+ ``xlims`` / ``ylims`` allow per-plot overrides: ``xlims: [(-3,3), None, (-1,5)]``.
110
+ Where omitted or ``None``, the global ``xmin/xmax`` or ``ymin/ymax`` apply.
111
+
112
+ Reference Line (y = a*x + b)
113
+ ----------------------------
114
+ ``lines`` takes per-axis slope/intercept specs. Each element may be a two-element
115
+ sequence ``[a, b]`` or ``(a, b)``, or an extended form ``[a, (x0,y0)]`` from which
116
+ ``b`` is derived as ``y0 - a*x0``.
117
+
118
+ Alpha / Line Width / Font Size
119
+ ------------------------------
120
+ * ``alpha`` – global transparency for function curves.
121
+ * ``lw`` – line width (float-like).
122
+ * ``fontsize`` – base font size applied to axes labels, tick labels, and legends.
123
+
124
+ Ticks & Grid
125
+ ------------
126
+ * ``ticks`` – truthy/falsey values (``true``, ``false``, ``on``, ``off``...). Defaults to True.
127
+ * ``grid`` – same parsing as ``ticks``.
128
+
129
+ Rows & Cols Auto Layout
130
+ -----------------------
131
+ ``rows`` and ``cols`` determine the subplot grid. If you omit ``cols`` we compute a
132
+ default that fits all functions given the specified rows.
133
+
134
+ Parsing Nuances
135
+ ---------------
136
+ The directive tolerates:
137
+ * Bracketed lists ``[a, b, c]`` or raw ``a, b, c`` strings.
138
+ * Semicolons as separators (converted to commas).
139
+ * Per-item parentheses / brackets / braces inside domain or line specs.
140
+
141
+ Debug Mode
142
+ ----------
143
+ ``:debug:`` (or ``debug:``) disables ID rewriting and size stripping so you can
144
+ inspect the raw produced SVG. (A PDF sidecar snippet is present in code but
145
+ commented out.)
146
+
147
+ No Hover Tooltips
148
+ -----------------
149
+ We intentionally do not inject ``<title>`` elements; they created distracting
150
+ hover popups. Accessibility is preserved through ``aria-label``.
151
+
152
+ Error Handling
153
+ --------------
154
+ If an expression fails SymPy parsing or evaluation, the build emits a Sphinx
155
+ error node indicating which function failed. Numerical issues (NaNs, infs) are
156
+ neutralized into ``NaN`` gaps, avoiding Matplotlib from drawing misleading spikes.
157
+
158
+ Option Reference (Summary)
159
+ --------------------------
160
+ Required:
161
+ * ``functions`` – list of function expressions.
162
+
163
+ Styling & Layout:
164
+ * ``width`` – percentage or px (auto-centers if %).
165
+ * ``rows`` / ``cols`` – grid shape.
166
+ * ``align`` – left | center | right (figure alignment class).
167
+ * ``class`` – extra CSS classes appended to figure container.
168
+ * ``alt`` – accessible description (defaults to generic text).
169
+
170
+ Axes & Appearance:
171
+ * ``xmin``, ``xmax``, ``ymin``, ``ymax`` – global ranges.
172
+ * ``xstep``, ``ystep`` – tick spacing.
173
+ * ``fontsize`` – base font size.
174
+ * ``lw`` – line width.
175
+ * ``alpha`` – line alpha (float).
176
+ * ``grid`` – toggle grid.
177
+ * ``ticks`` – toggle ticks.
178
+
179
+ Per-Function / Per-Axis:
180
+ * ``domains`` – list of domain specs with optional exclusions ``(a,b) \ {e1,e2}``.
181
+ * ``points`` – (legacy) per-axis point lists. Each element can be ``None`` (or omitted) or a
182
+ single tuple ``(x,y)`` or a list/tuple of tuples ``[(x1,y1),(x2,y2)]``. Examples:
183
+ ``points: [ (0,0), None, [(1,2),(2,3)] ]`` or using bracketless top-level splitting
184
+ ``points: [(0,0), None, ((1,2),(2,3))]``. Points are drawn as filled blue circles
185
+ with black edges after the function curve so they appear on top.
186
+ * ``point`` – (new) single point with axis targeting. Format: ``point: (x, y), axis_spec``
187
+ where ``axis_spec`` is either an integer (1-indexed flattened row-major index) or
188
+ a tuple ``(row, col)`` (1-indexed). Supports expression evaluation and function calls.
189
+ Examples: ``point: (1, f(2)), 1`` (axis 1) or ``point: (1, h(2)), (2, 1)`` (row 2, col 1).
190
+ Multiple ``point:`` lines can be used to add points to different axes.
191
+ * ``vlines`` / ``hlines`` – (legacy) per-axis vertical / horizontal reference lines.
192
+ * ``hline`` – (new) single horizontal line with axis targeting. Format: ``hline: y, axis_spec``
193
+ or ``hline: y, x1, x2, axis_spec`` where y is the line height and optional x1, x2 define
194
+ the horizontal extent (defaults to global xmin/xmax). Supports expression evaluation.
195
+ Examples: ``hline: 2, 1`` (full width at y=2 on axis 1) or ``hline: f(3), -2, 2, (1, 2)``
196
+ (from x=-2 to x=2 at y=f(3) on row 1, col 2).
197
+ * ``vline`` – (new) single vertical line with axis targeting. Format: ``vline: x, axis_spec``
198
+ or ``vline: x, y1, y2, axis_spec`` where x is the line position and optional y1, y2 define
199
+ the vertical extent (defaults to global ymin/ymax). Supports expression evaluation.
200
+ Examples: ``vline: 1, 1`` (full height at x=1 on axis 1) or ``vline: sqrt(2), -3, 3, (2, 1)``
201
+ (from y=-3 to y=3 at x=sqrt(2) on row 2, col 1).
202
+ * ``lines`` – (legacy) per-axis reference lines y = a*x + b or derived from basepoint.
203
+ * ``line`` – (new) single line with axis targeting. Format: ``line: a, b, axis_spec`` for y = a*x + b
204
+ or ``line: a, (x0, y0), axis_spec`` for point-slope form y = y0 + a*(x - x0).
205
+ Supports expression evaluation and function calls.
206
+ Examples: ``line: 2, 1, 1`` (y = 2x + 1 on axis 1) or ``line: f(3), (1, 2), (2, 1)``
207
+ (slope f(3) through point (1, 2) on row 2, col 1).
208
+ * ``tangent`` – (new) tangent line with axis targeting. Format: ``tangent: x0, function_label, axis_spec``
209
+ where x0 is the point of tangency and function_label is one of the function labels (f, g, h, etc.).
210
+ Computes derivative automatically using SymPy.
211
+ Examples: ``tangent: 2, f, 1`` (tangent to f at x=2 on axis 1) or ``tangent: sqrt(3), g, (2, 1)``
212
+ (tangent to g at x=sqrt(3) on row 2, col 1).
213
+ * ``text`` – (new) text annotation with axis targeting. Format: ``text: x, y, "text"[, placement], axis_spec``
214
+ where placement is optional (default ``top-left``) and axis_spec is optional (applies to all if omitted).
215
+ Position tokens: ``top-left``, ``top-right``, ``bottom-left``, ``bottom-right``, ``top-center``, ``bottom-center``,
216
+ ``center-left``, ``center-right``, ``center-center``. Long variants shift further from point: ``longtop-left``, etc.
217
+ Supports expression evaluation for x and y coordinates.
218
+ Examples: ``text: 1, 2, "Point A", 1`` or ``text: pi/2, sin(pi/2), "Peak", top-center, (1, 2)``.
219
+ * ``annotate`` – (new) arrow annotation with axis targeting. Format: ``annotate: (x_text, y_text), (x_target, y_target), "text"[, arc], axis_spec``
220
+ where arc is optional (default 0.3) and axis_spec is optional (applies to all if omitted).
221
+ Supports expression evaluation for all coordinates and arc curvature.
222
+ Examples: ``annotate: (1, 3), (2, 4), "Arrow", 1`` or ``annotate: (0, f(0)), (pi, f(pi)), "Peak", 0.5, (1, 1)``.
223
+ * ``xlims`` / ``ylims`` – (legacy) per-axis limits as tuples.
224
+ * ``xmin`` / ``xmax`` / ``ymin`` / ``ymax`` – (new) per-axis or global limits. Format: ``xmin: value, axis_spec``
225
+ or ``xmin: value`` (applies to all axes). Supports expression evaluation.
226
+ Examples: ``xmin: -10, 1`` (set xmin=-10 for axis 1) or ``ymax: 5`` (set ymax=5 for all axes).
227
+ * ``fn_labels`` / ``function-names`` – labels.
228
+
229
+ Meta / Control:
230
+ * ``nocache`` – force regeneration.
231
+ * ``debug`` – keep raw SVG + skip ID rewriting.
232
+ * ``name`` – explicit figure base name (influences cache filename).
233
+
234
+ Caption
235
+ -------
236
+ Any content after the parsed key/value block (or after inline key/value lines)
237
+ is treated as the caption and wrapped in a Sphinx ``<caption>`` node.
238
+
239
+ Implementation Notes
240
+ --------------------
241
+ * Expressions are compiled once per build variant and cached.
242
+ * Domain exclusions create widened gaps (± a few samples) for visual clarity.
243
+ * ID rewriting avoids collisions of gradients / clips when multiple multi-plot
244
+ images exist on the same page.
245
+ * Inline width styling is injected only once by a regex match on the first
246
+ ``<svg>`` tag; subsequent styling merges if a style attribute already exists.
247
+
248
+ This directive is specifically tuned for the pedagogical needs of the material
249
+ in this repository; tweak or extend as needed. If you add new options, please
250
+ update this docstring to keep the self-documenting pattern intact.
251
+ """
252
+
253
+ from __future__ import annotations
254
+
255
+ import os
256
+ import re
257
+ import uuid
258
+ import hashlib
259
+ import shutil
260
+ from typing import Callable, Dict, Any, Tuple, List
261
+
262
+ from docutils import nodes
263
+ from docutils.parsers.rst import directives
264
+ from sphinx.util.docutils import SphinxDirective
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Helpers
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ def _hash_key(*parts) -> str:
272
+ h = hashlib.sha1()
273
+ for p in parts:
274
+ if p is None:
275
+ p = "__NONE__"
276
+ h.update(str(p).encode("utf-8"))
277
+ h.update(b"||")
278
+ return h.hexdigest()[:12]
279
+
280
+
281
+ def _compile_function(expr: str) -> Callable:
282
+ import sympy, numpy as np # local import
283
+ from scipy import special as sp_special
284
+
285
+ expr = expr.strip()
286
+ x = sympy.symbols("x")
287
+ try:
288
+ sym = sympy.sympify(expr)
289
+ except Exception as e: # pragma: no cover - user error path
290
+ raise ValueError(f"Ugyldig funksjonsuttrykk '{expr}': {e}")
291
+
292
+ # Map special functions like erf to scipy implementations for numpy arrays
293
+ fn_np = sympy.lambdify(x, sym, modules=[{"erf": sp_special.erf}, "numpy"])
294
+
295
+ def f(arr):
296
+ return fn_np(np.asarray(arr, dtype=float))
297
+
298
+ # simple vectorization test
299
+ _ = f([0.0, 1.0])
300
+ return f
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Directive
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def _strip_root_svg_size(svg_text: str) -> str:
309
+ """Remove width/height only on the root <svg> tag for responsiveness."""
310
+
311
+ def repl(m):
312
+ tag = m.group(0)
313
+ tag = re.sub(r'\swidth="[^"]+"', "", tag)
314
+ tag = re.sub(r'\sheight="[^"]+"', "", tag)
315
+ return tag
316
+
317
+ return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
318
+
319
+
320
+ def _parse_bool(val, default: bool | None = None) -> bool | None:
321
+ if val is None:
322
+ return default
323
+ if isinstance(val, bool):
324
+ return val
325
+ s = str(val).strip().lower()
326
+ if s == "": # option present with no value => treat as True
327
+ return True
328
+ if s in {"true", "yes", "on", "1"}:
329
+ return True
330
+ if s in {"false", "no", "off", "0"}:
331
+ return False
332
+ return default
333
+
334
+
335
+ def _split_expr_list(val: str) -> List[str]:
336
+ if not isinstance(val, str):
337
+ return []
338
+ s = val.strip()
339
+ if not s:
340
+ return []
341
+ # allow [a,b,c] or a;b;c or a,b,c
342
+ if s.startswith("[") and s.endswith("]"):
343
+ s = s[1:-1]
344
+ s = s.replace(";", ",")
345
+ parts = [p.strip() for p in s.split(",")]
346
+ return [p for p in parts if p]
347
+
348
+
349
+ def _split_top_level(val: str) -> List[str]:
350
+ """Split by commas at top level only (ignores commas inside (), [], {})."""
351
+ if not isinstance(val, str):
352
+ return []
353
+ s = val.strip()
354
+ if not s:
355
+ return []
356
+ # Strip surrounding brackets if present
357
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
358
+ s = s[1:-1].strip()
359
+ out: List[str] = []
360
+ cur = []
361
+ depth = 0
362
+ pairs = {")": "(", "]": "[", "}": "{"}
363
+ stack: List[str] = []
364
+ i = 0
365
+ while i < len(s):
366
+ ch = s[i]
367
+ if ch in "([{":
368
+ depth += 1
369
+ stack.append(ch)
370
+ cur.append(ch)
371
+ elif ch in ")]}":
372
+ depth = max(0, depth - 1)
373
+ if stack:
374
+ stack.pop()
375
+ cur.append(ch)
376
+ elif ch == "," and depth == 0:
377
+ part = "".join(cur).strip()
378
+ if part:
379
+ out.append(part)
380
+ cur = []
381
+ else:
382
+ cur.append(ch)
383
+ i += 1
384
+ tail = "".join(cur).strip()
385
+ if tail:
386
+ out.append(tail)
387
+ return out
388
+
389
+
390
+ def _safe_literal(val: str):
391
+ try:
392
+ import ast as _ast
393
+
394
+ return _ast.literal_eval(val)
395
+ except Exception:
396
+ return None
397
+
398
+
399
+ def _parse_values_list_or_none(s: str):
400
+ """Parse a scalar number or a tuple/list of numbers; return list[float] or None."""
401
+ if not isinstance(s, str):
402
+ return None
403
+ st = s.strip()
404
+ if not st or st.lower() == "none":
405
+ return None
406
+ lit = _safe_literal(st)
407
+ if isinstance(lit, (int, float)):
408
+ try:
409
+ return [float(lit)]
410
+ except Exception:
411
+ return None
412
+ if isinstance(lit, (list, tuple)):
413
+ out: List[float] = []
414
+ for v in lit:
415
+ try:
416
+ out.append(float(v))
417
+ except Exception:
418
+ pass
419
+ return out if out else None
420
+ # fallback: split by commas
421
+ parts = [p.strip() for p in st.split(",") if p.strip()]
422
+ out2: List[float] = []
423
+ for p in parts:
424
+ try:
425
+ out2.append(float(p))
426
+ except Exception:
427
+ return None
428
+ return out2 if out2 else None
429
+
430
+
431
+ class MultiPlotDirective(SphinxDirective):
432
+ has_content = True
433
+ required_arguments = 0
434
+ option_spec = {
435
+ "functions": directives.unchanged_required, # list of expressions
436
+ "fn_labels": directives.unchanged, # optional list of labels
437
+ "function-names": directives.unchanged, # alias for fn_labels in examples
438
+ "domains": directives.unchanged, # per-function domain (a,b) or (a,b) \ {..}
439
+ "vlines": directives.unchanged, # per-function vline x or None
440
+ "hlines": directives.unchanged, # per-function hline y or None
441
+ "xlims": directives.unchanged, # per-function xlim tuple or None
442
+ "ylims": directives.unchanged, # per-function ylim tuple or None
443
+ "lines": directives.unchanged, # per-axis line spec: (a,b) or (a,(x,y)) or None
444
+ "points": directives.unchanged, # per-axis point lists: [(x,y),(x,y)] or None
445
+ "point": directives.unchanged, # single point with axis target: (x,y), axis_spec
446
+ "hline": directives.unchanged, # single hline with axis target: y, x1, x2, axis_spec
447
+ "vline": directives.unchanged, # single vline with axis target: x, y1, y2, axis_spec
448
+ "line": directives.unchanged, # single line with axis target: a, b, axis_spec or a, (x0,y0), axis_spec
449
+ "tangent": directives.unchanged, # single tangent line: x0, function_label, axis_spec
450
+ "xmin": directives.unchanged,
451
+ "xmax": directives.unchanged,
452
+ "ymin": directives.unchanged,
453
+ "ymax": directives.unchanged,
454
+ "text": directives.unchanged, # text annotation: x, y, "text"[, placement], axis_spec
455
+ "annotate": directives.unchanged, # arrow annotation: (xytext), (xy), "text"[, arc], axis_spec
456
+ "xstep": directives.unchanged,
457
+ "ystep": directives.unchanged,
458
+ "fontsize": directives.unchanged,
459
+ "lw": directives.unchanged,
460
+ "alpha": directives.unchanged,
461
+ "grid": directives.unchanged,
462
+ "ticks": directives.unchanged,
463
+ "rows": directives.unchanged,
464
+ "cols": directives.unchanged,
465
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
466
+ "class": directives.class_option,
467
+ "name": directives.unchanged,
468
+ "nocache": directives.flag,
469
+ "alt": directives.unchanged,
470
+ "width": directives.length_or_percentage_or_unitless,
471
+ "debug": directives.flag,
472
+ }
473
+
474
+ # -----------------------------
475
+ # Content key/value parsing
476
+ # -----------------------------
477
+ def _gather_kv_from_content(self) -> Tuple[Dict[str, str], int]:
478
+ kv: Dict[str, str] = {}
479
+ lines = list(self.content)
480
+ idx = 0
481
+ # YAML front matter style
482
+ if lines and lines[0].strip() == "---":
483
+ idx = 1
484
+ while idx < len(lines) and lines[idx].strip() != "---":
485
+ line = lines[idx].rstrip()
486
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
487
+ if m:
488
+ kv[m.group(1)] = m.group(2)
489
+ idx += 1
490
+ if idx < len(lines) and lines[idx].strip() == "---":
491
+ idx += 1
492
+ while idx < len(lines) and not lines[idx].strip():
493
+ idx += 1
494
+ return kv, idx
495
+ # flat key: value lines until first non-matching or blank separation
496
+ caption_start = 0
497
+ for i, line in enumerate(lines):
498
+ if not line.strip():
499
+ caption_start = i + 1
500
+ continue
501
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
502
+ if m:
503
+ kv[m.group(1)] = m.group(2)
504
+ caption_start = i + 1
505
+ else:
506
+ break
507
+ return kv, caption_start
508
+
509
+ # -----------------------------
510
+ # Main run
511
+ # -----------------------------
512
+ def run(self): # noqa: C901 (complexity OK for directive)
513
+ env = self.state.document.settings.env
514
+ app = env.app
515
+ try:
516
+ import plotmath # type: ignore
517
+ except Exception as e: # pragma: no cover - dependency missing
518
+ err = nodes.error()
519
+ err += nodes.paragraph(text=f"Kunne ikke importere plotmath: {e}")
520
+ return [err]
521
+
522
+ kv, caption_idx = self._gather_kv_from_content()
523
+ merged: Dict[str, Any] = {**kv, **self.options}
524
+ if "functions" not in merged:
525
+ return [
526
+ self.state_machine.reporter.error(
527
+ "Directive 'multi-plot' krever 'functions:'", line=self.lineno
528
+ )
529
+ ]
530
+
531
+ exprs = _split_expr_list(str(merged["functions"]))
532
+ if not exprs:
533
+ return [
534
+ self.state_machine.reporter.error(
535
+ "'functions' var tomt eller ugyldig.", line=self.lineno
536
+ )
537
+ ]
538
+ # compile all
539
+ functions: List[Callable] = []
540
+ for e in exprs:
541
+ try:
542
+ functions.append(_compile_function(e))
543
+ except Exception as ex:
544
+ return [
545
+ self.state_machine.reporter.error(
546
+ f"Ugyldig funksjon '{e}': {ex}", line=self.lineno
547
+ )
548
+ ]
549
+
550
+ def _f(name, default):
551
+ v = merged.get(name)
552
+ if v in (None, ""):
553
+ return default
554
+ try:
555
+ return float(v)
556
+ except Exception:
557
+ return default
558
+
559
+ xmin = _f("xmin", -6)
560
+ xmax = _f("xmax", 6)
561
+ ymin = _f("ymin", -6)
562
+ ymax = _f("ymax", 6)
563
+ xstep = _f("xstep", 1)
564
+ ystep = _f("ystep", 1)
565
+ fontsize = _f("fontsize", 20)
566
+ lw = _f("lw", 2.5)
567
+ alpha_raw = merged.get("alpha")
568
+ grid_flag = _parse_bool(merged.get("grid"), default=True)
569
+ ticks_flag = _parse_bool(merged.get("ticks"), default=True)
570
+
571
+ try:
572
+ alpha = float(alpha_raw) if alpha_raw not in (None, "") else None
573
+ except Exception:
574
+ alpha = None
575
+
576
+ # Accept both fn_labels and function-names; function-names takes precedence if provided
577
+ labels_list: List[str] = _split_expr_list(
578
+ str(merged.get("function-names", merged.get("fn_labels", "")))
579
+ )
580
+ if labels_list and len(labels_list) == len(functions):
581
+ labels_arg: Any = labels_list
582
+ else:
583
+ labels_arg = True
584
+
585
+ # Per-function domains with optional exclusions, vlines, hlines, and axis limits
586
+ # Helper to parse domain with optional set-difference exclusions
587
+ def _parse_domain_with_exclusions(s: str):
588
+ if not isinstance(s, str):
589
+ return None, []
590
+ s = s.strip()
591
+ if not s or s.lower() == "none":
592
+ return None, []
593
+ num_re = r"[+-]?\d+(?:\.\d+)?"
594
+ dom_ex_pat = re.compile(
595
+ rf"\(\s*({num_re})\s*,\s*({num_re})\s*\)\s*(?:\\\s*\{{\s*([^}}]*)\s*\}})?"
596
+ )
597
+ m = dom_ex_pat.search(s)
598
+ if not m:
599
+ return None, []
600
+ try:
601
+ d0 = float(m.group(1))
602
+ d1 = float(m.group(2))
603
+ dom = (d0, d1)
604
+ except Exception:
605
+ dom = None
606
+ excludes: List[float] = []
607
+ excl_str = m.group(3) if m.lastindex and m.lastindex >= 3 else None
608
+ if excl_str:
609
+ for tok in [t.strip() for t in excl_str.split(",") if t.strip()]:
610
+ try:
611
+ excludes.append(float(tok))
612
+ except Exception:
613
+ pass
614
+ return dom, excludes
615
+
616
+ def _parse_tuple_or_none(s: str):
617
+ if not isinstance(s, str):
618
+ return None
619
+ st = s.strip()
620
+ if not st or st.lower() == "none":
621
+ return None
622
+ lit = _safe_literal(st)
623
+ if isinstance(lit, (list, tuple)) and len(lit) == 2:
624
+ try:
625
+ return (float(lit[0]), float(lit[1]))
626
+ except Exception:
627
+ return None
628
+ m = re.match(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)", st)
629
+ if m:
630
+ try:
631
+ return (float(m.group(1)), float(m.group(2)))
632
+ except Exception:
633
+ return None
634
+ return None
635
+
636
+ def _parse_scalar_or_none(s: str):
637
+ if not isinstance(s, str):
638
+ return None
639
+ st = s.strip()
640
+ if not st or st.lower() == "none":
641
+ return None
642
+ try:
643
+ return float(st)
644
+ except Exception:
645
+ return None
646
+
647
+ domains_raw = _split_top_level(str(merged.get("domains", "")))
648
+ vlines_raw = _split_top_level(str(merged.get("vlines", "")))
649
+ hlines_raw = _split_top_level(str(merged.get("hlines", "")))
650
+ xlims_raw = _split_top_level(str(merged.get("xlims", "")))
651
+ ylims_raw = _split_top_level(str(merged.get("ylims", "")))
652
+ lines_raw = _split_top_level(str(merged.get("lines", "")))
653
+ points_raw = _split_top_level(str(merged.get("points", "")))
654
+
655
+ # Normalize sizes to match number of functions
656
+ n = len(functions)
657
+
658
+ def _pad(lst, fill="None"):
659
+ return lst + [fill] * max(0, n - len(lst))
660
+
661
+ domains_raw = _pad(domains_raw)
662
+ vlines_raw = _pad(vlines_raw)
663
+ hlines_raw = _pad(hlines_raw)
664
+ xlims_raw = _pad(xlims_raw)
665
+ ylims_raw = _pad(ylims_raw)
666
+ lines_raw = _pad(lines_raw)
667
+ points_raw = _pad(points_raw)
668
+
669
+ dom_list: List[Tuple[float, float] | None] = []
670
+ excl_list: List[List[float]] = []
671
+ for s in domains_raw[:n]:
672
+ dom, ex = _parse_domain_with_exclusions(s)
673
+ dom_list.append(dom)
674
+ excl_list.append(ex)
675
+
676
+ vline_vals: List[List[float] | None] = [
677
+ _parse_values_list_or_none(s) for s in vlines_raw[:n]
678
+ ]
679
+ hline_vals: List[List[float] | None] = [
680
+ _parse_values_list_or_none(s) for s in hlines_raw[:n]
681
+ ]
682
+ xlim_vals: List[Tuple[float, float] | None] = [
683
+ _parse_tuple_or_none(s) for s in xlims_raw[:n]
684
+ ]
685
+ ylim_vals: List[Tuple[float, float] | None] = [
686
+ _parse_tuple_or_none(s) for s in ylims_raw[:n]
687
+ ]
688
+
689
+ # Parse per-axis line specs
690
+ def _parse_line_spec(s: str):
691
+ if not isinstance(s, str):
692
+ return None
693
+ st = s.strip()
694
+ if not st or st.lower() == "none":
695
+ return None
696
+ lit = _safe_literal(st)
697
+ a_val = None
698
+ b_val = None
699
+ if isinstance(lit, (list, tuple)) and len(lit) >= 2:
700
+ try:
701
+ a_val = float(lit[0])
702
+ except Exception:
703
+ a_val = None
704
+ second = lit[1]
705
+ if isinstance(second, (list, tuple)) and len(second) == 2:
706
+ try:
707
+ x0p = float(second[0])
708
+ y0p = float(second[1])
709
+ if a_val is not None:
710
+ b_val = y0p - a_val * x0p
711
+ except Exception:
712
+ b_val = None
713
+ else:
714
+ try:
715
+ b_val = float(second)
716
+ except Exception:
717
+ b_val = None
718
+ if a_val is not None and b_val is not None:
719
+ return (a_val, b_val)
720
+ return None
721
+
722
+ line_specs: List[Tuple[float, float] | None] = [_parse_line_spec(s) for s in lines_raw[:n]]
723
+
724
+ # Parse per-axis points. Each entry can be:
725
+ # - "None" or empty => no points for that axis
726
+ # - a single tuple like (x,y)
727
+ # - a list/tuple of tuples: [(x1,y1),(x2,y2)] or ((x1,y1),(x2,y2))
728
+ # - a loose comma form: (x1,y1); (x2,y2)
729
+ def _parse_points_entry(s: str):
730
+ if not isinstance(s, str):
731
+ return None
732
+ st = s.strip()
733
+ if not st or st.lower() == "none":
734
+ return None
735
+ lit = _safe_literal(st)
736
+ points_list: List[Tuple[float, float]] = []
737
+
738
+ def _coerce_pair(obj):
739
+ try:
740
+ if (
741
+ isinstance(obj, (list, tuple))
742
+ and len(obj) == 2
743
+ and all(isinstance(v, (int, float)) for v in obj)
744
+ ):
745
+ return (float(obj[0]), float(obj[1]))
746
+ except Exception:
747
+ return None
748
+ return None
749
+
750
+ if isinstance(lit, (list, tuple)):
751
+ # Could be list of pairs or a single pair
752
+ if len(lit) == 2 and all(isinstance(v, (int, float)) for v in lit):
753
+ p = _coerce_pair(lit)
754
+ if p:
755
+ points_list.append(p)
756
+ else:
757
+ for item in lit:
758
+ p = _coerce_pair(item)
759
+ if p:
760
+ points_list.append(p)
761
+ return points_list or None
762
+ # Fallback: find all (x,y) pattern occurrences
763
+ import re as _re
764
+
765
+ matches = _re.findall(
766
+ r"\(\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*,\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*\)",
767
+ st,
768
+ )
769
+ for a, b in matches:
770
+ try:
771
+ points_list.append((float(a), float(b)))
772
+ except Exception:
773
+ pass
774
+ return points_list or None
775
+
776
+ points_vals: List[List[Tuple[float, float]] | None] = [
777
+ _parse_points_entry(s) for s in points_raw[:n]
778
+ ]
779
+
780
+ # Parse new-style point keyword with axis targeting
781
+ # Gather all point: entries from content
782
+ point_entries_raw = []
783
+ if "point" in merged:
784
+ # Single point from options
785
+ point_entries_raw.append(merged["point"])
786
+ # Also check content for multiple point: lines
787
+ for line_idx, line in enumerate(self.content):
788
+ m = re.match(r"^point\s*:\s*(.+)$", line.strip())
789
+ if m:
790
+ point_entries_raw.append(m.group(1))
791
+
792
+ explicit_name = merged.get("name")
793
+ debug_mode = "debug" in merged
794
+ rows = int(float(merged.get("rows", 1)))
795
+ # If cols not provided, default to enough columns to fit all functions over the given rows
796
+ default_cols = max(1, (len(functions) + rows - 1) // max(1, rows))
797
+ cols = int(float(merged.get("cols", default_cols)))
798
+
799
+ # Expression evaluator with function call support (similar to plot directive)
800
+ def _eval_expr_multiplot(val) -> float:
801
+ import sympy
802
+
803
+ if val is None:
804
+ raise ValueError("Empty value")
805
+ if isinstance(val, (int, float)):
806
+ return float(val)
807
+ s0 = str(val).strip()
808
+ if not s0:
809
+ raise ValueError("Blank numeric expression")
810
+ s = s0
811
+
812
+ # Replace function label calls with their values
813
+ # e.g., f(2) where 'f' is a function label
814
+ for _ in range(50): # iteration limit
815
+ m = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\(", s)
816
+ if not m:
817
+ break
818
+ lbl = m.group(1)
819
+ if lbl not in labels_list:
820
+ # Skip this label
821
+ start_next = m.start() + 1
822
+ n = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\(", s[start_next:])
823
+ if not n:
824
+ break
825
+ m = n
826
+ lbl = m.group(1)
827
+ if lbl not in labels_list:
828
+ break
829
+
830
+ # Find matching closing parenthesis
831
+ start = m.end() - 1 # position of '('
832
+ depth = 0
833
+ end = None
834
+ for i in range(start, len(s)):
835
+ if s[i] == "(":
836
+ depth += 1
837
+ elif s[i] == ")":
838
+ depth -= 1
839
+ if depth == 0:
840
+ end = i
841
+ break
842
+ if end is None:
843
+ break
844
+
845
+ arg_expr = s[start + 1 : end]
846
+ try:
847
+ arg_val = _eval_expr_multiplot(arg_expr)
848
+ idx = labels_list.index(lbl)
849
+ f = functions[idx]
850
+ import numpy as np
851
+
852
+ yv = float(f(np.array([arg_val]))[0])
853
+ s = s[: m.start()] + f"{yv}" + s[end + 1 :]
854
+ continue
855
+ except Exception:
856
+ break
857
+
858
+ # Evaluate remaining expression with sympy
859
+ allowed = {
860
+ k: getattr(sympy, k)
861
+ for k in [
862
+ "pi",
863
+ "E",
864
+ "exp",
865
+ "sqrt",
866
+ "log",
867
+ "sin",
868
+ "cos",
869
+ "tan",
870
+ "asin",
871
+ "acos",
872
+ "atan",
873
+ "Rational",
874
+ "erf",
875
+ ]
876
+ if hasattr(sympy, k)
877
+ }
878
+ try:
879
+ expr = sympy.sympify(s, locals=allowed)
880
+ return float(expr.evalf())
881
+ except Exception:
882
+ raise ValueError(f"Could not evaluate: {s0}")
883
+
884
+ # Parse point entries after rows/cols are determined
885
+ # Format: (x, y), axis_spec where axis_spec is either:
886
+ # - integer (1-indexed flattened, row-major)
887
+ # - (row, col) tuple (1-indexed)
888
+ def _parse_point_with_axis(s: str, rows: int, cols: int):
889
+ """Parse '(x, y), axis_spec' and return (x, y, flat_idx) or None."""
890
+ if not isinstance(s, str):
891
+ return None
892
+ s = s.strip()
893
+ if not s:
894
+ return None
895
+
896
+ # Split at top-level comma to separate (x,y) from axis_spec
897
+ parts = []
898
+ current = []
899
+ depth = 0
900
+ for ch in s:
901
+ if ch in "([{":
902
+ depth += 1
903
+ current.append(ch)
904
+ elif ch in ")]}":
905
+ depth -= 1
906
+ current.append(ch)
907
+ elif ch == "," and depth == 0:
908
+ parts.append("".join(current).strip())
909
+ current = []
910
+ else:
911
+ current.append(ch)
912
+ if current:
913
+ parts.append("".join(current).strip())
914
+
915
+ if len(parts) < 2:
916
+ return None
917
+
918
+ # First part should be (x, y) - can contain expressions or function calls
919
+ coord_str = parts[0].strip()
920
+
921
+ # Extract x and y expressions from (x_expr, y_expr)
922
+ if not (coord_str.startswith("(") and coord_str.endswith(")")):
923
+ return None
924
+
925
+ inner = coord_str[1:-1].strip()
926
+ # Split on top-level comma
927
+ comma_parts = []
928
+ current_part = []
929
+ depth = 0
930
+ for ch in inner:
931
+ if ch in "([{":
932
+ depth += 1
933
+ current_part.append(ch)
934
+ elif ch in ")]}":
935
+ depth -= 1
936
+ current_part.append(ch)
937
+ elif ch == "," and depth == 0:
938
+ comma_parts.append("".join(current_part).strip())
939
+ current_part = []
940
+ else:
941
+ current_part.append(ch)
942
+ if current_part:
943
+ comma_parts.append("".join(current_part).strip())
944
+
945
+ if len(comma_parts) != 2:
946
+ return None
947
+
948
+ x_expr, y_expr = comma_parts[0], comma_parts[1]
949
+
950
+ # Evaluate expressions (supports function calls like f(2))
951
+ try:
952
+ x_val = _eval_expr_multiplot(x_expr)
953
+ y_val = _eval_expr_multiplot(y_expr)
954
+ except Exception:
955
+ return None
956
+
957
+ # Second part is axis specifier
958
+ axis_str = parts[1]
959
+ lit_axis = _safe_literal(axis_str)
960
+
961
+ flat_idx = None
962
+ if isinstance(lit_axis, int):
963
+ # Direct flattened index (1-indexed)
964
+ flat_idx = lit_axis - 1 # Convert to 0-indexed
965
+ elif isinstance(lit_axis, (list, tuple)) and len(lit_axis) == 2:
966
+ # (row, col) format (1-indexed)
967
+ try:
968
+ row = int(lit_axis[0]) - 1 # Convert to 0-indexed
969
+ col = int(lit_axis[1]) - 1
970
+ if 0 <= row < rows and 0 <= col < cols:
971
+ flat_idx = row * cols + col
972
+ except Exception:
973
+ return None
974
+ else:
975
+ # Try to parse as int or (row, col)
976
+ try:
977
+ flat_idx = int(axis_str) - 1
978
+ except Exception:
979
+ # Try tuple
980
+ m = re.match(r"\(\s*(\d+)\s*,\s*(\d+)\s*\)", axis_str)
981
+ if m:
982
+ try:
983
+ row = int(m.group(1)) - 1
984
+ col = int(m.group(2)) - 1
985
+ if 0 <= row < rows and 0 <= col < cols:
986
+ flat_idx = row * cols + col
987
+ except Exception:
988
+ return None
989
+
990
+ if flat_idx is not None and 0 <= flat_idx < rows * cols:
991
+ return (x_val, y_val, flat_idx)
992
+ return None
993
+
994
+ # Process point entries and add to points_vals
995
+ for pt_entry in point_entries_raw:
996
+ parsed = _parse_point_with_axis(pt_entry, rows, cols)
997
+ if parsed:
998
+ x_val, y_val, flat_idx = parsed
999
+ # Add point to the appropriate axis
1000
+ if flat_idx < len(points_vals):
1001
+ if points_vals[flat_idx] is None:
1002
+ points_vals[flat_idx] = []
1003
+ points_vals[flat_idx].append((x_val, y_val))
1004
+
1005
+ # Parse new-style hline keyword with axis targeting
1006
+ # Gather all hline: entries from content
1007
+ hline_entries_raw = []
1008
+ if "hline" in merged:
1009
+ hline_entries_raw.append(merged["hline"])
1010
+ for line_idx, line in enumerate(self.content):
1011
+ m = re.match(r"^hline\s*:\s*(.+)$", line.strip())
1012
+ if m:
1013
+ hline_entries_raw.append(m.group(1))
1014
+
1015
+ # Helper to parse axis specifier (used by both point and hline)
1016
+ def _parse_axis_spec(axis_str: str, rows: int, cols: int):
1017
+ """Parse axis specifier and return flat index (0-indexed) or None."""
1018
+ lit_axis = _safe_literal(axis_str)
1019
+ flat_idx = None
1020
+
1021
+ if isinstance(lit_axis, int):
1022
+ flat_idx = lit_axis - 1
1023
+ elif isinstance(lit_axis, (list, tuple)) and len(lit_axis) == 2:
1024
+ try:
1025
+ row = int(lit_axis[0]) - 1
1026
+ col = int(lit_axis[1]) - 1
1027
+ if 0 <= row < rows and 0 <= col < cols:
1028
+ flat_idx = row * cols + col
1029
+ except Exception:
1030
+ return None
1031
+ else:
1032
+ try:
1033
+ flat_idx = int(axis_str) - 1
1034
+ except Exception:
1035
+ m = re.match(r"\(\s*(\d+)\s*,\s*(\d+)\s*\)", axis_str)
1036
+ if m:
1037
+ try:
1038
+ row = int(m.group(1)) - 1
1039
+ col = int(m.group(2)) - 1
1040
+ if 0 <= row < rows and 0 <= col < cols:
1041
+ flat_idx = row * cols + col
1042
+ except Exception:
1043
+ return None
1044
+
1045
+ if flat_idx is not None and 0 <= flat_idx < rows * cols:
1046
+ return flat_idx
1047
+ return None
1048
+
1049
+ # Parse hline entries: y, x1, x2, axis_spec or y, axis_spec
1050
+ # Format: y [, x1, x2], axis_spec
1051
+ def _parse_hline_with_axis(
1052
+ s: str, rows: int, cols: int, xmin_global: float, xmax_global: float
1053
+ ):
1054
+ """Parse 'y, [x1, x2,] axis_spec' and return (y, x1, x2, flat_idx) or None."""
1055
+ if not isinstance(s, str):
1056
+ return None
1057
+ s = s.strip()
1058
+ if not s:
1059
+ return None
1060
+
1061
+ # Split at top-level commas
1062
+ parts = []
1063
+ current = []
1064
+ depth = 0
1065
+ for ch in s:
1066
+ if ch in "([{":
1067
+ depth += 1
1068
+ current.append(ch)
1069
+ elif ch in ")]}":
1070
+ depth -= 1
1071
+ current.append(ch)
1072
+ elif ch == "," and depth == 0:
1073
+ parts.append("".join(current).strip())
1074
+ current = []
1075
+ else:
1076
+ current.append(ch)
1077
+ if current:
1078
+ parts.append("".join(current).strip())
1079
+
1080
+ if len(parts) < 2:
1081
+ return None
1082
+
1083
+ # Last part is always axis specifier
1084
+ axis_str = parts[-1]
1085
+ flat_idx = _parse_axis_spec(axis_str, rows, cols)
1086
+ if flat_idx is None:
1087
+ return None
1088
+
1089
+ # Parse y value (first part, supports expressions)
1090
+ try:
1091
+ y_val = _eval_expr_multiplot(parts[0])
1092
+ except Exception:
1093
+ return None
1094
+
1095
+ # Parse optional x1, x2
1096
+ x1_val, x2_val = None, None
1097
+ if len(parts) == 4: # y, x1, x2, axis_spec
1098
+ try:
1099
+ x1_val = _eval_expr_multiplot(parts[1])
1100
+ x2_val = _eval_expr_multiplot(parts[2])
1101
+ except Exception:
1102
+ pass
1103
+ elif (
1104
+ len(parts) == 3
1105
+ ): # y, x1, axis_spec (treat as y, axis_spec if x1 looks like axis spec)
1106
+ # Check if parts[1] could be axis spec
1107
+ test_idx = _parse_axis_spec(parts[1], rows, cols)
1108
+ if test_idx is not None:
1109
+ # parts[1] is actually the axis spec, parts[2] is extra
1110
+ return None
1111
+ # Otherwise parts[1] might be x1 - but we need x2 too for it to make sense
1112
+ # So treat as malformed for now
1113
+ pass
1114
+
1115
+ # Use global xmin/xmax if x1, x2 not specified
1116
+ if x1_val is None:
1117
+ x1_val = xmin_global
1118
+ if x2_val is None:
1119
+ x2_val = xmax_global
1120
+
1121
+ return (y_val, x1_val, x2_val, flat_idx)
1122
+
1123
+ # Convert hline_vals from list of lists to dict for easy axis-specific updates
1124
+ hline_dict: Dict[int, List[Tuple[float, float | None, float | None]]] = {}
1125
+ for idx in range(len(hline_vals)):
1126
+ if hline_vals[idx] is not None:
1127
+ # Legacy hlines are just y values - convert to (y, None, None)
1128
+ hline_dict[idx] = [(y, None, None) for y in hline_vals[idx]]
1129
+ else:
1130
+ hline_dict[idx] = []
1131
+
1132
+ # Process hline entries
1133
+ for hl_entry in hline_entries_raw:
1134
+ parsed = _parse_hline_with_axis(hl_entry, rows, cols, xmin, xmax)
1135
+ if parsed:
1136
+ y_val, x1_val, x2_val, flat_idx = parsed
1137
+ if flat_idx not in hline_dict:
1138
+ hline_dict[flat_idx] = []
1139
+ hline_dict[flat_idx].append((y_val, x1_val, x2_val))
1140
+
1141
+ # Convert back to list format for plotting code
1142
+ for idx in range(len(hline_vals)):
1143
+ if idx in hline_dict and hline_dict[idx]:
1144
+ # Keep only y values for backward compatibility with existing plotting code
1145
+ # But we need to handle x1, x2 differently - let's store tuples
1146
+ hline_vals[idx] = hline_dict[idx]
1147
+ elif idx in hline_dict:
1148
+ hline_vals[idx] = None
1149
+
1150
+ # Parse new-style vline keyword with axis targeting
1151
+ # Gather all vline: entries from content
1152
+ vline_entries_raw = []
1153
+ if "vline" in merged:
1154
+ vline_entries_raw.append(merged["vline"])
1155
+ for line_idx, line in enumerate(self.content):
1156
+ m = re.match(r"^vline\s*:\s*(.+)$", line.strip())
1157
+ if m:
1158
+ vline_entries_raw.append(m.group(1))
1159
+
1160
+ # Parse vline entries: x, y1, y2, axis_spec or x, axis_spec
1161
+ # Format: x [, y1, y2], axis_spec
1162
+ def _parse_vline_with_axis(
1163
+ s: str, rows: int, cols: int, ymin_global: float, ymax_global: float
1164
+ ):
1165
+ """Parse 'x, [y1, y2,] axis_spec' and return (x, y1, y2, flat_idx) or None."""
1166
+ if not isinstance(s, str):
1167
+ return None
1168
+ s = s.strip()
1169
+ if not s:
1170
+ return None
1171
+
1172
+ # Split at top-level commas
1173
+ parts = []
1174
+ current = []
1175
+ depth = 0
1176
+ for ch in s:
1177
+ if ch in "([{":
1178
+ depth += 1
1179
+ current.append(ch)
1180
+ elif ch in ")]}":
1181
+ depth -= 1
1182
+ current.append(ch)
1183
+ elif ch == "," and depth == 0:
1184
+ parts.append("".join(current).strip())
1185
+ current = []
1186
+ else:
1187
+ current.append(ch)
1188
+ if current:
1189
+ parts.append("".join(current).strip())
1190
+
1191
+ if len(parts) < 2:
1192
+ return None
1193
+
1194
+ # Last part is always axis specifier
1195
+ axis_str = parts[-1]
1196
+ flat_idx = _parse_axis_spec(axis_str, rows, cols)
1197
+ if flat_idx is None:
1198
+ return None
1199
+
1200
+ # Parse x value (first part, supports expressions)
1201
+ try:
1202
+ x_val = _eval_expr_multiplot(parts[0])
1203
+ except Exception:
1204
+ return None
1205
+
1206
+ # Parse optional y1, y2
1207
+ y1_val, y2_val = None, None
1208
+ if len(parts) == 4: # x, y1, y2, axis_spec
1209
+ try:
1210
+ y1_val = _eval_expr_multiplot(parts[1])
1211
+ y2_val = _eval_expr_multiplot(parts[2])
1212
+ except Exception:
1213
+ pass
1214
+ elif (
1215
+ len(parts) == 3
1216
+ ): # x, y1, axis_spec (treat as x, axis_spec if y1 looks like axis spec)
1217
+ # Check if parts[1] could be axis spec
1218
+ test_idx = _parse_axis_spec(parts[1], rows, cols)
1219
+ if test_idx is not None:
1220
+ # parts[1] is actually the axis spec, parts[2] is extra
1221
+ return None
1222
+ # Otherwise parts[1] might be y1 - but we need y2 too for it to make sense
1223
+ # So treat as malformed for now
1224
+ pass
1225
+
1226
+ # Use global ymin/ymax if y1, y2 not specified
1227
+ if y1_val is None:
1228
+ y1_val = ymin_global
1229
+ if y2_val is None:
1230
+ y2_val = ymax_global
1231
+
1232
+ return (x_val, y1_val, y2_val, flat_idx)
1233
+
1234
+ # Convert vline_vals from list of lists to dict for easy axis-specific updates
1235
+ vline_dict: Dict[int, List[Tuple[float, float | None, float | None]]] = {}
1236
+ for idx in range(len(vline_vals)):
1237
+ if vline_vals[idx] is not None:
1238
+ # Legacy vlines are just x values - convert to (x, None, None)
1239
+ vline_dict[idx] = [(x, None, None) for x in vline_vals[idx]]
1240
+ else:
1241
+ vline_dict[idx] = []
1242
+
1243
+ # Process vline entries
1244
+ for vl_entry in vline_entries_raw:
1245
+ parsed = _parse_vline_with_axis(vl_entry, rows, cols, ymin, ymax)
1246
+ if parsed:
1247
+ x_val, y1_val, y2_val, flat_idx = parsed
1248
+ if flat_idx not in vline_dict:
1249
+ vline_dict[flat_idx] = []
1250
+ vline_dict[flat_idx].append((x_val, y1_val, y2_val))
1251
+
1252
+ # Convert back to list format for plotting code
1253
+ for idx in range(len(vline_vals)):
1254
+ if idx in vline_dict and vline_dict[idx]:
1255
+ vline_vals[idx] = vline_dict[idx]
1256
+ elif idx in vline_dict:
1257
+ vline_vals[idx] = None
1258
+
1259
+ # Parse new-style line keyword with axis targeting
1260
+ # Gather all line: entries from content
1261
+ line_entries_raw = []
1262
+ if "line" in merged:
1263
+ line_entries_raw.append(merged["line"])
1264
+ for line_idx, line in enumerate(self.content):
1265
+ m = re.match(r"^line\s*:\s*(.+)$", line.strip())
1266
+ if m:
1267
+ line_entries_raw.append(m.group(1))
1268
+
1269
+ # Parse line entries: a, b, axis_spec or a, (x0, y0), axis_spec
1270
+ # Format: a, b, axis_spec -> y = a*x + b
1271
+ # a, (x0, y0), axis_spec -> y = y0 + a*(x - x0)
1272
+ def _parse_line_with_axis(s: str, rows: int, cols: int):
1273
+ """Parse 'a, b, axis_spec' or 'a, (x0, y0), axis_spec' and return (a, b, flat_idx) or None."""
1274
+ if not isinstance(s, str):
1275
+ return None
1276
+ s = s.strip()
1277
+ if not s:
1278
+ return None
1279
+
1280
+ # Split at top-level commas
1281
+ parts = []
1282
+ current = []
1283
+ depth = 0
1284
+ for ch in s:
1285
+ if ch in "([{":
1286
+ depth += 1
1287
+ current.append(ch)
1288
+ elif ch in ")]}":
1289
+ depth -= 1
1290
+ current.append(ch)
1291
+ elif ch == "," and depth == 0:
1292
+ parts.append("".join(current).strip())
1293
+ current = []
1294
+ else:
1295
+ current.append(ch)
1296
+ if current:
1297
+ parts.append("".join(current).strip())
1298
+
1299
+ if len(parts) < 3:
1300
+ return None
1301
+
1302
+ # Last part is always axis specifier
1303
+ axis_str = parts[-1]
1304
+ flat_idx = _parse_axis_spec(axis_str, rows, cols)
1305
+ if flat_idx is None:
1306
+ return None
1307
+
1308
+ # Parse slope a (first part, supports expressions)
1309
+ try:
1310
+ a_val = _eval_expr_multiplot(parts[0])
1311
+ except Exception:
1312
+ return None
1313
+
1314
+ # Parse second part: either b or (x0, y0)
1315
+ second_part = parts[1].strip()
1316
+ b_val = None
1317
+
1318
+ # Check if it's a tuple (x0, y0)
1319
+ if second_part.startswith("(") and second_part.endswith(")"):
1320
+ # Extract x0, y0 from tuple
1321
+ inner = second_part[1:-1].strip()
1322
+ # Split on top-level comma
1323
+ coord_parts = []
1324
+ coord_current = []
1325
+ coord_depth = 0
1326
+ for ch in inner:
1327
+ if ch in "([{":
1328
+ coord_depth += 1
1329
+ coord_current.append(ch)
1330
+ elif ch in ")]}":
1331
+ coord_depth -= 1
1332
+ coord_current.append(ch)
1333
+ elif ch == "," and coord_depth == 0:
1334
+ coord_parts.append("".join(coord_current).strip())
1335
+ coord_current = []
1336
+ else:
1337
+ coord_current.append(ch)
1338
+ if coord_current:
1339
+ coord_parts.append("".join(coord_current).strip())
1340
+
1341
+ if len(coord_parts) == 2:
1342
+ try:
1343
+ x0_val = _eval_expr_multiplot(coord_parts[0])
1344
+ y0_val = _eval_expr_multiplot(coord_parts[1])
1345
+ # Convert to y = a*x + b form: y = y0 + a*(x - x0) => y = a*x + (y0 - a*x0)
1346
+ b_val = y0_val - a_val * x0_val
1347
+ except Exception:
1348
+ return None
1349
+ else:
1350
+ return None
1351
+ else:
1352
+ # It's just b
1353
+ try:
1354
+ b_val = _eval_expr_multiplot(second_part)
1355
+ except Exception:
1356
+ return None
1357
+
1358
+ if b_val is not None:
1359
+ return (a_val, b_val, flat_idx)
1360
+ return None
1361
+
1362
+ # Convert line_specs from list to dict for easy axis-specific updates
1363
+ # Now each axis can have multiple lines, so we store lists of tuples
1364
+ line_dict: Dict[int, List[Tuple[float, float]]] = {}
1365
+ for idx in range(len(line_specs)):
1366
+ if line_specs[idx] is not None:
1367
+ # Legacy single-line format: convert to list
1368
+ line_dict[idx] = [line_specs[idx]]
1369
+ else:
1370
+ line_dict[idx] = []
1371
+
1372
+ # Process line entries
1373
+ for ln_entry in line_entries_raw:
1374
+ parsed = _parse_line_with_axis(ln_entry, rows, cols)
1375
+ if parsed:
1376
+ a_val, b_val, flat_idx = parsed
1377
+ if flat_idx not in line_dict:
1378
+ line_dict[flat_idx] = []
1379
+ line_dict[flat_idx].append((a_val, b_val))
1380
+
1381
+ # Convert back to list format for plotting code
1382
+ for idx in range(len(line_specs)):
1383
+ if idx in line_dict and line_dict[idx]:
1384
+ # Store list of lines
1385
+ line_specs[idx] = line_dict[idx]
1386
+ else:
1387
+ # No lines for this axis
1388
+ line_specs[idx] = None
1389
+
1390
+ # Process tangent keyword: tangent: x0, function_label, axis_spec
1391
+ tangent_entries_raw = []
1392
+ if "tangent" in merged:
1393
+ tangent_entries_raw.append(merged["tangent"])
1394
+ for line in self.content:
1395
+ m = re.match(r"^tangent\s*:\s*(.+)$", line.strip())
1396
+ if m:
1397
+ tangent_entries_raw.append(m.group(1))
1398
+
1399
+ def _parse_tangent_with_axis(s: str, rows: int, cols: int):
1400
+ """
1401
+ Parse: x0, function_label, axis_spec
1402
+ Returns (x0_val, func_label, flat_idx) or None
1403
+ """
1404
+ import sympy
1405
+
1406
+ # Split by commas at top level
1407
+ parts = _split_top_level(s)
1408
+ if len(parts) < 3:
1409
+ return None
1410
+
1411
+ # Last part is axis_spec
1412
+ axis_part = parts[-1].strip()
1413
+ flat_idx = _parse_axis_spec(axis_part, rows, cols)
1414
+ if flat_idx is None:
1415
+ return None
1416
+
1417
+ # Second-to-last is function label
1418
+ func_label = parts[-2].strip()
1419
+
1420
+ # Everything before second-to-last is x0 expression
1421
+ x0_str = ",".join(parts[:-2]).strip()
1422
+
1423
+ # Evaluate x0 using expression evaluator
1424
+ try:
1425
+ x0_val = _eval_expr_multiplot(x0_str)
1426
+ except Exception:
1427
+ return None
1428
+
1429
+ return (x0_val, func_label, flat_idx)
1430
+
1431
+ # Process tangent entries and add to line_specs
1432
+ for tg_entry in tangent_entries_raw:
1433
+ parsed = _parse_tangent_with_axis(tg_entry, rows, cols)
1434
+ if parsed:
1435
+ x0_val, func_label, flat_idx = parsed
1436
+
1437
+ # Find the function index matching the label
1438
+ func_idx = None
1439
+ if labels_list:
1440
+ for i, lbl in enumerate(labels_list):
1441
+ if lbl.strip() == func_label:
1442
+ func_idx = i
1443
+ break
1444
+
1445
+ if func_idx is not None and func_idx < len(functions):
1446
+ # Compute tangent line: y = f'(x0) * (x - x0) + f(x0)
1447
+ # Which is: y = f'(x0) * x + (f(x0) - x0 * f'(x0))
1448
+ import sympy
1449
+
1450
+ x = sympy.symbols("x")
1451
+ try:
1452
+ expr_str = exprs[func_idx] # Use expression string, not compiled function
1453
+ sym = sympy.sympify(expr_str)
1454
+ sym_deriv = sympy.diff(sym, x)
1455
+
1456
+ # Evaluate f(x0) and f'(x0)
1457
+ f_x0 = float(sym.subs(x, x0_val))
1458
+ fp_x0 = float(sym_deriv.subs(x, x0_val))
1459
+
1460
+ # Tangent line: y = fp_x0 * x + (f_x0 - x0_val * fp_x0)
1461
+ a_tangent = fp_x0
1462
+ b_tangent = f_x0 - x0_val * fp_x0
1463
+
1464
+ # Add to line_specs (append to existing list)
1465
+ if line_specs[flat_idx] is None:
1466
+ line_specs[flat_idx] = []
1467
+ if isinstance(line_specs[flat_idx], tuple):
1468
+ # Convert legacy single-line format to list
1469
+ line_specs[flat_idx] = [line_specs[flat_idx]]
1470
+ line_specs[flat_idx].append((a_tangent, b_tangent))
1471
+ except Exception:
1472
+ pass
1473
+
1474
+ # Process per-axis xmin/xmax/ymin/ymax keywords
1475
+ # These can be specified with or without axis targeting
1476
+ # Format: xmin: value, axis_spec OR xmin: value (applies to all)
1477
+
1478
+ def _parse_limit_with_axis(s: str, rows: int, cols: int):
1479
+ """
1480
+ Parse: value, axis_spec OR just value (applies to all)
1481
+ Returns (value, flat_idx) or (value, None) for all axes
1482
+ """
1483
+ parts = _split_top_level(s)
1484
+ if len(parts) == 0:
1485
+ return None
1486
+
1487
+ if len(parts) == 1:
1488
+ # Just a value, applies to all axes
1489
+ try:
1490
+ val = _eval_expr_multiplot(parts[0])
1491
+ return (val, None) # None means apply to all
1492
+ except Exception:
1493
+ return None
1494
+
1495
+ # Two or more parts: value, axis_spec
1496
+ axis_part = parts[-1].strip()
1497
+ flat_idx = _parse_axis_spec(axis_part, rows, cols)
1498
+ if flat_idx is None:
1499
+ return None
1500
+
1501
+ # Everything before last part is the value
1502
+ val_str = ",".join(parts[:-1]).strip()
1503
+ try:
1504
+ val = _eval_expr_multiplot(val_str)
1505
+ return (val, flat_idx)
1506
+ except Exception:
1507
+ return None
1508
+
1509
+ # Gather xmin entries
1510
+ xmin_entries_raw = []
1511
+ if "xmin" in merged and merged["xmin"]:
1512
+ xmin_entries_raw.append(merged["xmin"])
1513
+ for line in self.content:
1514
+ m = re.match(r"^xmin\s*:\s*(.+)$", line.strip())
1515
+ if m:
1516
+ xmin_entries_raw.append(m.group(1))
1517
+
1518
+ # Gather xmax entries
1519
+ xmax_entries_raw = []
1520
+ if "xmax" in merged and merged["xmax"]:
1521
+ xmax_entries_raw.append(merged["xmax"])
1522
+ for line in self.content:
1523
+ m = re.match(r"^xmax\s*:\s*(.+)$", line.strip())
1524
+ if m:
1525
+ xmax_entries_raw.append(m.group(1))
1526
+
1527
+ # Gather ymin entries
1528
+ ymin_entries_raw = []
1529
+ if "ymin" in merged and merged["ymin"]:
1530
+ ymin_entries_raw.append(merged["ymin"])
1531
+ for line in self.content:
1532
+ m = re.match(r"^ymin\s*:\s*(.+)$", line.strip())
1533
+ if m:
1534
+ ymin_entries_raw.append(m.group(1))
1535
+
1536
+ # Gather ymax entries
1537
+ ymax_entries_raw = []
1538
+ if "ymax" in merged and merged["ymax"]:
1539
+ ymax_entries_raw.append(merged["ymax"])
1540
+ for line in self.content:
1541
+ m = re.match(r"^ymax\s*:\s*(.+)$", line.strip())
1542
+ if m:
1543
+ ymax_entries_raw.append(m.group(1))
1544
+
1545
+ # Process xmin entries
1546
+ for xmin_entry in xmin_entries_raw:
1547
+ parsed = _parse_limit_with_axis(xmin_entry, rows, cols)
1548
+ if parsed:
1549
+ val, flat_idx = parsed
1550
+ if flat_idx is None:
1551
+ # Apply to all axes
1552
+ for idx in range(len(xlim_vals)):
1553
+ if xlim_vals[idx] is None:
1554
+ xlim_vals[idx] = (val, xmax)
1555
+ else:
1556
+ xlim_vals[idx] = (val, xlim_vals[idx][1])
1557
+ else:
1558
+ # Apply to specific axis
1559
+ if xlim_vals[flat_idx] is None:
1560
+ xlim_vals[flat_idx] = (val, xmax)
1561
+ else:
1562
+ xlim_vals[flat_idx] = (val, xlim_vals[flat_idx][1])
1563
+
1564
+ # Process xmax entries
1565
+ for xmax_entry in xmax_entries_raw:
1566
+ parsed = _parse_limit_with_axis(xmax_entry, rows, cols)
1567
+ if parsed:
1568
+ val, flat_idx = parsed
1569
+ if flat_idx is None:
1570
+ # Apply to all axes
1571
+ for idx in range(len(xlim_vals)):
1572
+ if xlim_vals[idx] is None:
1573
+ xlim_vals[idx] = (xmin, val)
1574
+ else:
1575
+ xlim_vals[idx] = (xlim_vals[idx][0], val)
1576
+ else:
1577
+ # Apply to specific axis
1578
+ if xlim_vals[flat_idx] is None:
1579
+ xlim_vals[flat_idx] = (xmin, val)
1580
+ else:
1581
+ xlim_vals[flat_idx] = (xlim_vals[flat_idx][0], val)
1582
+
1583
+ # Process ymin entries
1584
+ for ymin_entry in ymin_entries_raw:
1585
+ parsed = _parse_limit_with_axis(ymin_entry, rows, cols)
1586
+ if parsed:
1587
+ val, flat_idx = parsed
1588
+ if flat_idx is None:
1589
+ # Apply to all axes
1590
+ for idx in range(len(ylim_vals)):
1591
+ if ylim_vals[idx] is None:
1592
+ ylim_vals[idx] = (val, ymax)
1593
+ else:
1594
+ ylim_vals[idx] = (val, ylim_vals[idx][1])
1595
+ else:
1596
+ # Apply to specific axis
1597
+ if ylim_vals[flat_idx] is None:
1598
+ ylim_vals[flat_idx] = (val, ymax)
1599
+ else:
1600
+ ylim_vals[flat_idx] = (val, ylim_vals[flat_idx][1])
1601
+
1602
+ # Process ymax entries
1603
+ for ymax_entry in ymax_entries_raw:
1604
+ parsed = _parse_limit_with_axis(ymax_entry, rows, cols)
1605
+ if parsed:
1606
+ val, flat_idx = parsed
1607
+ if flat_idx is None:
1608
+ # Apply to all axes
1609
+ for idx in range(len(ylim_vals)):
1610
+ if ylim_vals[idx] is None:
1611
+ ylim_vals[idx] = (ymin, val)
1612
+ else:
1613
+ ylim_vals[idx] = (ylim_vals[idx][0], val)
1614
+ else:
1615
+ # Apply to specific axis
1616
+ if ylim_vals[flat_idx] is None:
1617
+ ylim_vals[flat_idx] = (ymin, val)
1618
+ else:
1619
+ ylim_vals[flat_idx] = (ylim_vals[flat_idx][0], val)
1620
+
1621
+ # Include per-axis settings in the hash to prevent stale caches
1622
+
1623
+ # ─────────────────────────────────────────────────────────────
1624
+ # Helper for parsing text positioning (copied from plot.py)
1625
+ # ─────────────────────────────────────────────────────────────
1626
+ def _parse_text_positioning(pos: str) -> Tuple[str, str]:
1627
+ """Map positioning string to (va, ha). Default is (top, left)."""
1628
+ if not isinstance(pos, str):
1629
+ return ("top", "left")
1630
+ key = pos.strip().lower().replace("_", "-")
1631
+ mapping = {
1632
+ "top-left": ("bottom", "right"),
1633
+ "top-right": ("bottom", "left"),
1634
+ "bottom-left": ("top", "right"),
1635
+ "bottom-right": ("top", "left"),
1636
+ "top-center": ("bottom", "center"),
1637
+ "bottom-center": ("top", "center"),
1638
+ "center-left": ("center", "right"),
1639
+ "center-right": ("center", "left"),
1640
+ "longtop-left": ("longbottom", "left"),
1641
+ "longtop-longleft": ("longbottom", "longright"),
1642
+ "longbottom-right": ("longtop", "right"),
1643
+ "longbottom-left": ("longtop", "left"),
1644
+ "longtop-center": ("longbottom", "center"),
1645
+ "longbottom-center": ("longtop", "center"),
1646
+ "longtop-longright": ("longbottom", "longleft"),
1647
+ "longbottom-longright": ("longtop", "longleft"),
1648
+ "longtop-longleft": ("longbottom", "longright"),
1649
+ "longbottom-longleft": ("longtop", "longright"),
1650
+ "top-longleft": ("bottom", "longright"),
1651
+ "top-longright": ("bottom", "longleft"),
1652
+ "bottom-longleft": ("top", "longright"),
1653
+ "bottom-longright": ("top", "longleft"),
1654
+ "center-longleft": ("center", "longright"),
1655
+ "center-longright": ("center", "longleft"),
1656
+ "center-center": ("center", "center"),
1657
+ }
1658
+ return mapping.get(key, ("top", "left"))
1659
+
1660
+ # ─────────────────────────────────────────────────────────────
1661
+ # Helper for parsing text annotation with axis specifier
1662
+ # ─────────────────────────────────────────────────────────────
1663
+ def _parse_text_with_axis(
1664
+ entry: str, rows: int, cols: int
1665
+ ) -> Tuple[float, float, str, str, int | None] | None:
1666
+ """
1667
+ Parse: x, y, "text"[, placement], axis_spec
1668
+ Returns: (x_val, y_val, text_str, placement_str, flat_idx) or None
1669
+ If axis_spec is omitted, flat_idx is None (applies to all axes).
1670
+ """
1671
+ parts = _split_top_level(entry)
1672
+ if len(parts) < 3:
1673
+ return None
1674
+
1675
+ # Parse x and y as expressions
1676
+ try:
1677
+ x_val = _eval_expr_multiplot(parts[0].strip())
1678
+ y_val = _eval_expr_multiplot(parts[1].strip())
1679
+ except Exception:
1680
+ return None
1681
+
1682
+ # Parse text string (may be quoted)
1683
+ text_raw = parts[2].strip()
1684
+ text_str = text_raw.strip('"').strip("'")
1685
+
1686
+ # Determine placement and axis_spec based on number of parts
1687
+ placement_str = "top-left" # default
1688
+ axis_spec_raw = None
1689
+
1690
+ if len(parts) == 3:
1691
+ # No placement or axis_spec
1692
+ flat_idx = None
1693
+ elif len(parts) == 4:
1694
+ # Either placement or axis_spec
1695
+ candidate = parts[3].strip()
1696
+ # Check if it's a valid placement token
1697
+ pos_keys = {
1698
+ "top-left",
1699
+ "top-right",
1700
+ "bottom-left",
1701
+ "bottom-right",
1702
+ "top-center",
1703
+ "bottom-center",
1704
+ "center-left",
1705
+ "center-right",
1706
+ "center-center",
1707
+ "longtop-left",
1708
+ "longtop-longleft",
1709
+ "longbottom-right",
1710
+ "longbottom-left",
1711
+ "longtop-center",
1712
+ "longbottom-center",
1713
+ "longtop-longright",
1714
+ "longbottom-longright",
1715
+ "top-longleft",
1716
+ "top-longright",
1717
+ "bottom-longleft",
1718
+ "bottom-longright",
1719
+ "center-longleft",
1720
+ "center-longright",
1721
+ }
1722
+ if candidate.lower().replace("_", "-") in pos_keys:
1723
+ placement_str = candidate
1724
+ flat_idx = None
1725
+ else:
1726
+ # Treat as axis_spec
1727
+ axis_spec_raw = candidate
1728
+ parsed_axis = _parse_axis_spec(axis_spec_raw, rows, cols)
1729
+ flat_idx = parsed_axis if parsed_axis is not None else None
1730
+ elif len(parts) == 5:
1731
+ # Both placement and axis_spec
1732
+ placement_str = parts[3].strip()
1733
+ axis_spec_raw = parts[4].strip()
1734
+ parsed_axis = _parse_axis_spec(axis_spec_raw, rows, cols)
1735
+ flat_idx = parsed_axis if parsed_axis is not None else None
1736
+ else:
1737
+ return None
1738
+
1739
+ return (x_val, y_val, text_str, placement_str, flat_idx)
1740
+
1741
+ # ─────────────────────────────────────────────────────────────
1742
+ # Parse text annotations
1743
+ # ─────────────────────────────────────────────────────────────
1744
+ # Format: text: x, y, "text"[, placement], axis_spec
1745
+ # Storage: per-axis dictionary of lists of (x, y, text, placement) tuples
1746
+ text_dict: Dict[int, List[Tuple[float, float, str, str]]] = {}
1747
+
1748
+ text_entries_raw = []
1749
+ # From option
1750
+ text_opt = self.options.get("text", "").strip()
1751
+ if text_opt:
1752
+ text_entries_raw.append(text_opt)
1753
+ # From content
1754
+ for line in self.content:
1755
+ m = re.match(r"^text\s*:\s*(.+)$", line.strip())
1756
+ if m:
1757
+ text_entries_raw.append(m.group(1))
1758
+
1759
+ for text_entry in text_entries_raw:
1760
+ parsed = _parse_text_with_axis(text_entry, rows, cols)
1761
+ if parsed:
1762
+ x_val, y_val, text_str, placement_str, flat_idx = parsed
1763
+ if flat_idx is None:
1764
+ # Apply to all axes
1765
+ for idx in range(rows * cols):
1766
+ if idx not in text_dict:
1767
+ text_dict[idx] = []
1768
+ text_dict[idx].append((x_val, y_val, text_str, placement_str))
1769
+ else:
1770
+ # Apply to specific axis
1771
+ if flat_idx not in text_dict:
1772
+ text_dict[flat_idx] = []
1773
+ text_dict[flat_idx].append((x_val, y_val, text_str, placement_str))
1774
+
1775
+ # ─────────────────────────────────────────────────────────────
1776
+ # Helper for parsing annotate with axis specifier
1777
+ # ─────────────────────────────────────────────────────────────
1778
+ def _parse_annotate_with_axis(
1779
+ entry: str, rows: int, cols: int
1780
+ ) -> Tuple[Tuple[float, float], Tuple[float, float], str, float, int | None] | None:
1781
+ """
1782
+ Parse: (x_text, y_text), (x_target, y_target), "text"[, arc], axis_spec
1783
+ Returns: ((x_text, y_text), (x_target, y_target), text_str, arc_val, flat_idx) or None
1784
+ If axis_spec is omitted, flat_idx is None (applies to all axes).
1785
+ """
1786
+ s = entry.strip()
1787
+
1788
+ # Find first two balanced tuples
1789
+ def _grab_tuple(start_index: int) -> Tuple[int, int, str] | None:
1790
+ if start_index >= len(s) or s[start_index] != "(":
1791
+ return None
1792
+ depth = 0
1793
+ for j in range(start_index, len(s)):
1794
+ if s[j] == "(":
1795
+ depth += 1
1796
+ elif s[j] == ")":
1797
+ depth -= 1
1798
+ if depth == 0:
1799
+ inner = s[start_index + 1 : j]
1800
+ return (start_index, j, inner)
1801
+ return None
1802
+
1803
+ # Locate first '('
1804
+ i1 = s.find("(")
1805
+ if i1 == -1:
1806
+ return None
1807
+ t1 = _grab_tuple(i1)
1808
+ if not t1:
1809
+ return None
1810
+ i2_search = t1[1] + 1
1811
+ # Skip commas/space
1812
+ while i2_search < len(s) and s[i2_search] in " ,":
1813
+ i2_search += 1
1814
+ if i2_search >= len(s) or s[i2_search] != "(":
1815
+ return None
1816
+ t2 = _grab_tuple(i2_search)
1817
+ if not t2:
1818
+ return None
1819
+ rest = s[t2[1] + 1 :].strip()
1820
+
1821
+ # Split tuple inners on top-level comma
1822
+ def _split_pair(inner: str) -> Tuple[str, str] | None:
1823
+ depth = 0
1824
+ for k, ch in enumerate(inner):
1825
+ if ch == "(":
1826
+ depth += 1
1827
+ elif ch == ")":
1828
+ depth -= 1
1829
+ elif ch == "," and depth == 0:
1830
+ left = inner[:k].strip()
1831
+ right = inner[k + 1 :].strip()
1832
+ if left and right:
1833
+ return (left, right)
1834
+ return None
1835
+
1836
+ p1 = _split_pair(t1[2])
1837
+ p2 = _split_pair(t2[2])
1838
+ if not (p1 and p2):
1839
+ return None
1840
+
1841
+ # Extract quoted text
1842
+ import re
1843
+
1844
+ m_txt = re.search(
1845
+ r"\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|\'([^\'\\]*(?:\\.[^\'\\]*)*)\'",
1846
+ rest,
1847
+ )
1848
+ if not m_txt:
1849
+ return None
1850
+ text_str = m_txt.group(1) if m_txt.group(1) is not None else m_txt.group(2)
1851
+ after = rest[m_txt.end() :].strip().lstrip(",").strip()
1852
+
1853
+ # Parse remaining parts: [arc,] axis_spec
1854
+ remaining_parts = _split_top_level(after) if after else []
1855
+ arc_val = 0.3 # default
1856
+ flat_idx = None
1857
+
1858
+ if len(remaining_parts) == 0:
1859
+ # No arc or axis_spec
1860
+ pass
1861
+ elif len(remaining_parts) == 1:
1862
+ # Could be arc or axis_spec
1863
+ candidate = remaining_parts[0]
1864
+ # Try to parse as axis_spec first
1865
+ parsed_axis = _parse_axis_spec(candidate, rows, cols)
1866
+ if parsed_axis is not None:
1867
+ flat_idx = parsed_axis
1868
+ else:
1869
+ # Try as arc value
1870
+ try:
1871
+ arc_val = _eval_expr_multiplot(candidate)
1872
+ except Exception:
1873
+ pass
1874
+ elif len(remaining_parts) >= 2:
1875
+ # First is arc, second is axis_spec
1876
+ try:
1877
+ arc_val = _eval_expr_multiplot(remaining_parts[0])
1878
+ except Exception:
1879
+ pass
1880
+ parsed_axis = _parse_axis_spec(remaining_parts[1], rows, cols)
1881
+ if parsed_axis is not None:
1882
+ flat_idx = parsed_axis
1883
+
1884
+ # Evaluate coordinates
1885
+ try:
1886
+ x_text = _eval_expr_multiplot(p1[0])
1887
+ y_text = _eval_expr_multiplot(p1[1])
1888
+ x_target = _eval_expr_multiplot(p2[0])
1889
+ y_target = _eval_expr_multiplot(p2[1])
1890
+ except Exception:
1891
+ return None
1892
+
1893
+ return ((x_text, y_text), (x_target, y_target), text_str, arc_val, flat_idx)
1894
+
1895
+ # ─────────────────────────────────────────────────────────────
1896
+ # Parse annotate annotations
1897
+ # ─────────────────────────────────────────────────────────────
1898
+ # Format: annotate: (x_text, y_text), (x_target, y_target), "text"[, arc], axis_spec
1899
+ # Storage: per-axis dictionary of lists of ((xytext), (xy), text, arc) tuples
1900
+ annotate_dict: Dict[
1901
+ int, List[Tuple[Tuple[float, float], Tuple[float, float], str, float]]
1902
+ ] = {}
1903
+
1904
+ annotate_entries_raw = []
1905
+ # From option
1906
+ annotate_opt = self.options.get("annotate", "").strip()
1907
+ if annotate_opt:
1908
+ annotate_entries_raw.append(annotate_opt)
1909
+ # From content
1910
+ for line in self.content:
1911
+ m = re.match(r"^annotate\s*:\s*(.+)$", line.strip())
1912
+ if m:
1913
+ annotate_entries_raw.append(m.group(1))
1914
+
1915
+ for annotate_entry in annotate_entries_raw:
1916
+ parsed = _parse_annotate_with_axis(annotate_entry, rows, cols)
1917
+ if parsed:
1918
+ xytext, xy, text_str, arc_val, flat_idx = parsed
1919
+ if flat_idx is None:
1920
+ # Apply to all axes
1921
+ for idx in range(rows * cols):
1922
+ if idx not in annotate_dict:
1923
+ annotate_dict[idx] = []
1924
+ annotate_dict[idx].append((xytext, xy, text_str, arc_val))
1925
+ else:
1926
+ # Apply to specific axis
1927
+ if flat_idx not in annotate_dict:
1928
+ annotate_dict[flat_idx] = []
1929
+ annotate_dict[flat_idx].append((xytext, xy, text_str, arc_val))
1930
+
1931
+ # Include per-axis settings in the hash to prevent stale caches
1932
+ content_hash = _hash_key(
1933
+ "|".join(exprs),
1934
+ "|".join(labels_list),
1935
+ xmin,
1936
+ xmax,
1937
+ ymin,
1938
+ ymax,
1939
+ xstep,
1940
+ ystep,
1941
+ fontsize,
1942
+ lw,
1943
+ alpha,
1944
+ rows,
1945
+ cols,
1946
+ int(grid_flag),
1947
+ int(ticks_flag),
1948
+ "|".join(["" if d is None else f"{d[0]},{d[1]}" for d in dom_list]),
1949
+ "|".join(["|".join(map(str, exs)) if exs else "" for exs in excl_list]),
1950
+ "|".join(["|".join(map(str, vs)) if vs else "" for vs in vline_vals]),
1951
+ "|".join(["|".join(map(str, hs)) if hs else "" for hs in hline_vals]),
1952
+ "|".join(["" if xl is None else f"{xl[0]},{xl[1]}" for xl in xlim_vals]),
1953
+ "|".join(["" if yl is None else f"{yl[0]},{yl[1]}" for yl in ylim_vals]),
1954
+ "|".join(
1955
+ [
1956
+ (
1957
+ ""
1958
+ if ls is None
1959
+ else (
1960
+ ";".join([f"{a},{b}" for a, b in ls])
1961
+ if isinstance(ls, list)
1962
+ else f"{ls[0]},{ls[1]}"
1963
+ )
1964
+ )
1965
+ for ls in line_specs
1966
+ ]
1967
+ ),
1968
+ "|".join(
1969
+ [
1970
+ "" if pv is None else ";".join([f"{p[0]},{p[1]}" for p in pv])
1971
+ for pv in points_vals
1972
+ ]
1973
+ ),
1974
+ "|".join(
1975
+ [
1976
+ (
1977
+ ""
1978
+ if idx not in text_dict
1979
+ else ";".join([f"{x},{y},{t},{p}" for x, y, t, p in text_dict[idx]])
1980
+ )
1981
+ for idx in range(rows * cols)
1982
+ ]
1983
+ ),
1984
+ "|".join(
1985
+ [
1986
+ (
1987
+ ""
1988
+ if idx not in annotate_dict
1989
+ else ";".join(
1990
+ [
1991
+ f"{xt[0]},{xt[1]},{xy[0]},{xy[1]},{t},{a}"
1992
+ for xt, xy, t, a in annotate_dict[idx]
1993
+ ]
1994
+ )
1995
+ )
1996
+ for idx in range(rows * cols)
1997
+ ]
1998
+ ),
1999
+ )
2000
+ base_name = explicit_name or f"multi_plot_{content_hash}"
2001
+
2002
+ rel_dir = os.path.join("_static", "multi_plot")
2003
+ abs_dir = os.path.join(app.srcdir, rel_dir)
2004
+ os.makedirs(abs_dir, exist_ok=True)
2005
+ svg_name = f"{base_name}.svg"
2006
+ abs_svg = os.path.join(abs_dir, svg_name)
2007
+
2008
+ regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
2009
+ if regenerate:
2010
+ try:
2011
+ letters = [chr(i) for i in range(65, 65 + len(functions))]
2012
+ # Create axes grid without auto-plotting functions
2013
+ fig, axes = plotmath.multiplot(
2014
+ functions=[],
2015
+ fn_labels=False,
2016
+ xmin=xmin,
2017
+ xmax=xmax,
2018
+ ymin=ymin,
2019
+ ymax=ymax,
2020
+ xstep=xstep,
2021
+ ystep=ystep,
2022
+ ticks=ticks_flag,
2023
+ grid=grid_flag,
2024
+ rows=rows,
2025
+ cols=cols,
2026
+ lw=lw,
2027
+ alpha=alpha,
2028
+ fontsize=fontsize,
2029
+ figsize=(4.5 * cols, 3.5 * rows),
2030
+ )
2031
+ # Normalize axes to flat list
2032
+ try:
2033
+ import numpy as _np
2034
+
2035
+ axes_list = (
2036
+ list(axes.flatten())
2037
+ if hasattr(axes, "flatten")
2038
+ else list(_np.array(axes).flatten())
2039
+ )
2040
+ except Exception:
2041
+ axes_list = axes if isinstance(axes, (list, tuple)) else [axes]
2042
+
2043
+ # Ensure tick label font size matches provided fontsize (legend handled later)
2044
+ try:
2045
+ for _ax in axes_list:
2046
+ try:
2047
+ _ax.tick_params(labelsize=int(fontsize))
2048
+ except Exception:
2049
+ pass
2050
+ except Exception:
2051
+ pass
2052
+
2053
+ # Manual plotting per-axis
2054
+ import numpy as np
2055
+
2056
+ for idx, (expr, fn) in enumerate(zip(exprs, functions)):
2057
+ if idx >= len(axes_list):
2058
+ break
2059
+ ax = axes_list[idx]
2060
+ # Per-axis domain
2061
+ dom = dom_list[idx]
2062
+ x0, x1 = dom if dom is not None else (xmin, xmax)
2063
+ N = int(2**12)
2064
+ x = np.linspace(x0, x1, N)
2065
+ y = fn(x)
2066
+ # Ensure float array and blank out non-finite values
2067
+ y = np.asarray(y, dtype=float)
2068
+ y[~np.isfinite(y)] = np.nan
2069
+ # Robust exclusions: widen window and clear neighbors
2070
+ exs = [e for e in excl_list[idx] if x0 < e < x1]
2071
+ if exs and N > 1:
2072
+ dx = (x1 - x0) / (N - 1)
2073
+ w = max(4 * dx, 1e-6 * (1.0 + max(abs(e) for e in exs)))
2074
+ for e in exs:
2075
+ try:
2076
+ mask = np.abs(x - e) <= w
2077
+ if mask.any():
2078
+ y[mask] = np.nan
2079
+ j = int(np.argmin(np.abs(x - e)))
2080
+ for k in (j - 2, j - 1, j, j + 1, j + 2):
2081
+ if 0 <= k < y.size:
2082
+ y[k] = np.nan
2083
+ except Exception:
2084
+ try:
2085
+ j = int(np.argmin(np.abs(x - e)))
2086
+ if 0 <= j < y.size:
2087
+ y[j] = np.nan
2088
+ except Exception:
2089
+ pass
2090
+ # Also break across steep jumps or extreme magnitudes
2091
+ # Determine per-axis y-span preference: use provided ylim for this axis if any
2092
+ if ylim_vals[idx] is not None:
2093
+ y0_lim, y1_lim = ylim_vals[idx]
2094
+ else:
2095
+ y0_lim, y1_lim = ymin, ymax
2096
+ try:
2097
+ y_span = abs(float(y1_lim) - float(y0_lim))
2098
+ except Exception:
2099
+ y_span = np.nan
2100
+ if not (isinstance(y_span, (int, float)) and y_span > 0):
2101
+ finite_y = y[np.isfinite(y)]
2102
+ if finite_y.size > 0:
2103
+ y_span = float(np.nanmax(finite_y) - np.nanmin(finite_y))
2104
+ if not (isinstance(y_span, (int, float)) and y_span > 0):
2105
+ y_span = 1.0
2106
+ finite_pair = np.isfinite(y[:-1]) & np.isfinite(y[1:])
2107
+ jump_factor = 0.5
2108
+ big_jump = finite_pair & (np.abs(y[1:] - y[:-1]) > (jump_factor * y_span))
2109
+ if big_jump.any():
2110
+ idx_break = np.where(big_jump)[0]
2111
+ for i_b in idx_break:
2112
+ if 0 <= i_b + 1 < y.size:
2113
+ y[i_b + 1] = np.nan
2114
+ mag_factor = 50.0
2115
+ too_big = np.isfinite(y) & (np.abs(y) > (mag_factor * y_span))
2116
+ if too_big.any():
2117
+ y[too_big] = np.nan
2118
+ lbl = labels_list[idx] if (labels_list and idx < len(labels_list)) else None
2119
+ if lbl:
2120
+ ax.plot(x, y, lw=lw, alpha=alpha, label=f"${lbl}$")
2121
+ ax.legend(fontsize=int(fontsize))
2122
+ else:
2123
+ ax.plot(x, y, lw=lw, alpha=alpha)
2124
+ # Plot per-axis points if provided
2125
+ try:
2126
+ pv = points_vals[idx]
2127
+ if pv:
2128
+ xs = [p[0] for p in pv]
2129
+ ys = [p[1] for p in pv]
2130
+ ax.plot(
2131
+ xs,
2132
+ ys,
2133
+ linestyle="none",
2134
+ marker="o",
2135
+ markersize=max(4, min(12, int(fontsize) // 2)),
2136
+ color="black",
2137
+ alpha=0.8,
2138
+ )
2139
+ except Exception:
2140
+ pass
2141
+ # Optional line(s) y = a*x + b per axis
2142
+ if line_specs[idx] is not None:
2143
+ # Handle both legacy format (single tuple) and new format (list of tuples)
2144
+ lines_to_plot = []
2145
+ if isinstance(line_specs[idx], tuple):
2146
+ lines_to_plot = [line_specs[idx]]
2147
+ elif isinstance(line_specs[idx], list):
2148
+ lines_to_plot = line_specs[idx]
2149
+
2150
+ for line_spec in lines_to_plot:
2151
+ try:
2152
+ a_l, b_l = line_spec # type: ignore[misc]
2153
+ # Use provided xlim for this axis if any; else global
2154
+ if xlim_vals[idx] is not None:
2155
+ x_min_line, x_max_line = xlim_vals[idx]
2156
+ else:
2157
+ x_min_line, x_max_line = xmin, xmax
2158
+ x_line = np.array(
2159
+ [float(x_min_line), float(x_max_line)], dtype=float
2160
+ )
2161
+ y_line = a_l * x_line + b_l
2162
+ ax.plot(
2163
+ x_line,
2164
+ y_line,
2165
+ linestyle="--",
2166
+ color=plotmath.COLORS.get("red"),
2167
+ lw=lw,
2168
+ alpha=alpha,
2169
+ )
2170
+ except Exception:
2171
+ pass
2172
+ # vlines / hlines (support multiple values per axis)
2173
+ for xv in vline_vals[idx] or []:
2174
+ try:
2175
+ # Support both old format (float) and new format (tuple)
2176
+ if isinstance(xv, tuple) and len(xv) == 3:
2177
+ x_val, y1_val, y2_val = xv
2178
+ # If y1, y2 specified, use plot line segment
2179
+ if y1_val is not None and y2_val is not None:
2180
+ import numpy as np
2181
+
2182
+ x_line = np.array([float(x_val), float(x_val)])
2183
+ y_line = np.array([float(y1_val), float(y2_val)])
2184
+ ax.plot(
2185
+ x_line,
2186
+ y_line,
2187
+ color=plotmath.COLORS.get("red"),
2188
+ linestyle="--",
2189
+ lw=lw,
2190
+ )
2191
+ else:
2192
+ # Full vertical line
2193
+ ax.axvline(
2194
+ x=float(x_val),
2195
+ color=plotmath.COLORS.get("red"),
2196
+ linestyle="--",
2197
+ lw=lw,
2198
+ )
2199
+ else:
2200
+ # Old format: just x value
2201
+ ax.axvline(
2202
+ x=float(xv),
2203
+ color=plotmath.COLORS.get("red"),
2204
+ linestyle="--",
2205
+ lw=lw,
2206
+ )
2207
+ except Exception:
2208
+ pass
2209
+ for yh in hline_vals[idx] or []:
2210
+ try:
2211
+ # Support both old format (float) and new format (tuple)
2212
+ if isinstance(yh, tuple) and len(yh) == 3:
2213
+ y_val, x1_val, x2_val = yh
2214
+ # If x1, x2 specified, use axhspan or plot line segment
2215
+ if x1_val is not None and x2_val is not None:
2216
+ import numpy as np
2217
+
2218
+ x_line = np.array([float(x1_val), float(x2_val)])
2219
+ y_line = np.array([float(y_val), float(y_val)])
2220
+ ax.plot(
2221
+ x_line,
2222
+ y_line,
2223
+ color=plotmath.COLORS.get("red"),
2224
+ linestyle="--",
2225
+ lw=lw,
2226
+ )
2227
+ else:
2228
+ # Full horizontal line
2229
+ ax.axhline(
2230
+ y=float(y_val),
2231
+ color=plotmath.COLORS.get("red"),
2232
+ linestyle="--",
2233
+ lw=lw,
2234
+ )
2235
+ else:
2236
+ # Old format: just y value
2237
+ ax.axhline(
2238
+ y=float(yh),
2239
+ color=plotmath.COLORS.get("red"),
2240
+ linestyle="--",
2241
+ lw=lw,
2242
+ )
2243
+ except Exception:
2244
+ pass
2245
+
2246
+ # Draw text annotations
2247
+ if idx in text_dict:
2248
+ # Get axes dimensions for offset calculation
2249
+ try:
2250
+ fig.canvas.draw() # ensure layout is realized
2251
+ _bbox_px = ax.get_window_extent()
2252
+ _ax_w_px, _ax_h_px = _bbox_px.width, _bbox_px.height
2253
+ if _ax_w_px <= 0 or _ax_h_px <= 0:
2254
+ _ax_w_px = _ax_h_px = None
2255
+ except Exception:
2256
+ _ax_w_px = _ax_h_px = None
2257
+
2258
+ # Get current axis limits for fallback offset calculation
2259
+ try:
2260
+ ax_xlim = ax.get_xlim()
2261
+ ax_ylim = ax.get_ylim()
2262
+ ax_dx = abs(ax_xlim[1] - ax_xlim[0])
2263
+ ax_dy = abs(ax_ylim[1] - ax_ylim[0])
2264
+ except Exception:
2265
+ ax_dx = ax_dy = 1.0
2266
+
2267
+ for x0, y0, text_str, pos_str in text_dict[idx]:
2268
+ va, ha = _parse_text_positioning(pos_str)
2269
+
2270
+ # Offset factors (matching plot.py)
2271
+ _fx_short = 0.015
2272
+ _fy_short = 0.015
2273
+ _fx_long = 0.03
2274
+ _fy_long = 0.03
2275
+
2276
+ # Resolve long* variants
2277
+ _use_fx = _fx_short
2278
+ _use_fy = _fy_short
2279
+ if va == "longbottom":
2280
+ va = "bottom"
2281
+ _use_fy = _fy_long
2282
+ elif va == "longtop":
2283
+ va = "top"
2284
+ _use_fy = _fy_long
2285
+ if ha == "longright":
2286
+ ha = "right"
2287
+ _use_fx = _fx_long
2288
+ elif ha == "longleft":
2289
+ ha = "left"
2290
+ _use_fx = _fx_long
2291
+
2292
+ # Calculate offset
2293
+ if _ax_w_px and _ax_h_px:
2294
+ # Pixel-based offsets
2295
+ dx_px = 0.0
2296
+ dy_px = 0.0
2297
+ if ha == "right":
2298
+ dx_px = -_ax_w_px * _use_fx
2299
+ elif ha == "left":
2300
+ dx_px = _ax_w_px * _use_fx
2301
+ if va == "bottom":
2302
+ dy_px = _ax_h_px * _use_fy
2303
+ elif va == "top":
2304
+ dy_px = -_ax_h_px * _use_fy
2305
+ x_disp, y_disp = ax.transData.transform((x0, y0))
2306
+ x1, y1 = ax.transData.inverted().transform(
2307
+ (x_disp + dx_px, y_disp + dy_px)
2308
+ )
2309
+ dx = x1 - x0
2310
+ dy = y1 - y0
2311
+ else:
2312
+ # Fallback: data-space offsets
2313
+ if va == "bottom":
2314
+ dy = (_fy_short if _use_fy == _fy_short else _fy_long) * ax_dy
2315
+ elif va == "top":
2316
+ dy = -(_fy_short if _use_fy == _fy_short else _fy_long) * ax_dy
2317
+ else:
2318
+ dy = 0.0
2319
+ if ha == "right":
2320
+ dx = -(_fx_short if _use_fx == _fx_short else _fx_long) * ax_dx
2321
+ elif ha == "left":
2322
+ dx = (_fx_short if _use_fx == _fx_short else _fx_long) * ax_dx
2323
+ else:
2324
+ dx = 0.0
2325
+
2326
+ # Draw text
2327
+ ax.text(
2328
+ x0 + dx,
2329
+ y0 + dy,
2330
+ text_str,
2331
+ fontsize=int(fontsize),
2332
+ ha=ha,
2333
+ va=va,
2334
+ )
2335
+
2336
+ # Draw arrow annotations
2337
+ if idx in annotate_dict:
2338
+ import matplotlib.pyplot as plt
2339
+
2340
+ plt.sca(ax) # Set current axes
2341
+ for xytext, xy, text_str, arc_val in annotate_dict[idx]:
2342
+ plotmath.annotate(
2343
+ xy=xy,
2344
+ xytext=xytext,
2345
+ s=text_str,
2346
+ arc=arc_val,
2347
+ fontsize=int(fontsize),
2348
+ )
2349
+
2350
+ # x/ylims
2351
+ if xlim_vals[idx] is not None:
2352
+ ax.set_xlim(*xlim_vals[idx])
2353
+ if ylim_vals[idx] is not None:
2354
+ ax.set_ylim(*ylim_vals[idx])
2355
+ # Save via the single Figure object
2356
+ fig.savefig(abs_svg, format="svg", bbox_inches="tight", transparent=True)
2357
+ # Also save a PDF sidecar for debugging comparisons (optional)
2358
+ # try:
2359
+ # fig.savefig(
2360
+ # os.path.join(abs_dir, f"{base_name}.pdf"),
2361
+ # format="pdf",
2362
+ # bbox_inches="tight",
2363
+ # transparent=True,
2364
+ # )
2365
+ # except Exception:
2366
+ # pass
2367
+ import matplotlib
2368
+
2369
+ matplotlib.pyplot.close(fig)
2370
+ except Exception as e:
2371
+ return [
2372
+ self.state_machine.reporter.error(
2373
+ f"Feil under generering av graf: {e}", line=self.lineno
2374
+ )
2375
+ ]
2376
+
2377
+ if not os.path.exists(abs_svg):
2378
+ return [self.state_machine.reporter.error("multi-plot: SVG mangler.", line=self.lineno)]
2379
+
2380
+ env.note_dependency(abs_svg)
2381
+ try: # copy to output _static
2382
+ out_static = os.path.join(app.outdir, "_static", "multi_plot")
2383
+ os.makedirs(out_static, exist_ok=True)
2384
+ shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
2385
+ except Exception:
2386
+ pass
2387
+
2388
+ try:
2389
+ raw_svg = open(abs_svg, "r", encoding="utf-8").read()
2390
+ except Exception as e:
2391
+ return [
2392
+ self.state_machine.reporter.error(
2393
+ f"graph inline: kunne ikke lese SVG: {e}", line=self.lineno
2394
+ )
2395
+ ]
2396
+
2397
+ if not debug_mode and "viewBox" in raw_svg:
2398
+ raw_svg = _strip_root_svg_size(raw_svg)
2399
+
2400
+ def _rewrite_ids(txt: str, prefix: str) -> str:
2401
+ # Collect ids
2402
+ ids = re.findall(r'\bid="([^"]+)"', txt)
2403
+ if not ids:
2404
+ return txt
2405
+ # Skip font glyphs to avoid disrupting text rendering
2406
+ skip_prefixes = (
2407
+ "DejaVu",
2408
+ "CM",
2409
+ "STIX",
2410
+ "Nimbus",
2411
+ "Bitstream",
2412
+ "Arial",
2413
+ "Times",
2414
+ "Helvetica",
2415
+ )
2416
+ mapping = {}
2417
+ for i in ids:
2418
+ if i.startswith(skip_prefixes):
2419
+ continue
2420
+ mapping[i] = f"{prefix}{i}"
2421
+ if not mapping:
2422
+ return txt
2423
+
2424
+ # Replace id definitions
2425
+ def repl_id(m: re.Match) -> str:
2426
+ old = m.group(1)
2427
+ new = mapping.get(old, old)
2428
+ return f'id="{new}"'
2429
+
2430
+ txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
2431
+
2432
+ # Replace url(#id) everywhere (attributes and styles)
2433
+ def repl_url(m: re.Match) -> str:
2434
+ old = m.group(1).strip()
2435
+ new = mapping.get(old, old)
2436
+ return f"url(#{new})"
2437
+
2438
+ txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
2439
+
2440
+ # Replace href/xlink:href references supporting both quote styles
2441
+ def repl_href(m: re.Match) -> str:
2442
+ attr = m.group(1)
2443
+ quote = m.group(2)
2444
+ old = m.group(3).strip()
2445
+ new = mapping.get(old, old)
2446
+ return f"{attr}={quote}#{new}{quote}"
2447
+
2448
+ txt = re.sub(
2449
+ r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2',
2450
+ repl_href,
2451
+ txt,
2452
+ )
2453
+ return txt
2454
+
2455
+ if not debug_mode:
2456
+ raw_svg = _rewrite_ids(raw_svg, f"mgr_{content_hash}_{uuid.uuid4().hex[:6]}_")
2457
+
2458
+ alt_default = f"Multiplot av {len(exprs)} funksjoner"
2459
+ alt = merged.get("alt", alt_default)
2460
+
2461
+ width_opt = merged.get("width")
2462
+ percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
2463
+
2464
+ def _augment(m):
2465
+ tag = m.group(0)
2466
+ if "class=" not in tag:
2467
+ tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
2468
+ else:
2469
+ tag = tag.replace('class="', 'class="graph-inline-svg ')
2470
+ if alt and "aria-label=" not in tag:
2471
+ tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
2472
+ if width_opt:
2473
+ if percent:
2474
+ wval = width_opt.strip()
2475
+ else:
2476
+ wval = width_opt.strip()
2477
+ if wval.isdigit():
2478
+ wval += "px"
2479
+ style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
2480
+ if "style=" in tag:
2481
+ tag = re.sub(
2482
+ r'style="([^"]*)"',
2483
+ lambda mm: f'style="{mm.group(1)}; {style_frag}"',
2484
+ tag,
2485
+ count=1,
2486
+ )
2487
+ else:
2488
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
2489
+ return tag
2490
+
2491
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
2492
+ # Intentionally do not inject a <title> element to avoid hover tooltips; accessibility
2493
+ # remains via role="img" and aria-label attributes. Add manually later if truly needed.
2494
+
2495
+ figure = nodes.figure()
2496
+ figure.setdefault("classes", []).extend(
2497
+ ["adaptive-figure", "multi-plot-figure", "no-click"]
2498
+ )
2499
+ raw_node = nodes.raw("", raw_svg, format="html")
2500
+ raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
2501
+ figure += raw_node
2502
+
2503
+ extra_classes = merged.get("class")
2504
+ if extra_classes:
2505
+ figure["classes"].extend(extra_classes)
2506
+ figure["align"] = merged.get("align", "center")
2507
+
2508
+ caption_lines = list(self.content)[caption_idx:]
2509
+ while caption_lines and not caption_lines[0].strip():
2510
+ caption_lines.pop(0)
2511
+ if caption_lines:
2512
+ caption = nodes.caption()
2513
+ caption += nodes.Text("\n".join(caption_lines))
2514
+ figure += caption
2515
+
2516
+ if explicit_name:
2517
+ self.add_name(figure)
2518
+ return [figure]
2519
+
2520
+
2521
+ def setup(app): # pragma: no cover
2522
+ app.add_directive("multi-plot", MultiPlotDirective)
2523
+ app.add_directive("multiplot", MultiPlotDirective) # Also register without hyphen
2524
+ return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}