munchboka-edutools 0.1.13__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (149) hide show
  1. munchboka_edutools/__init__.py +184 -0
  2. munchboka_edutools/_plotmath_shim.py +126 -0
  3. munchboka_edutools/_version.py +2 -0
  4. munchboka_edutools/directives/__init__.py +1 -0
  5. munchboka_edutools/directives/admonitions.py +389 -0
  6. munchboka_edutools/directives/cas_popup.py +272 -0
  7. munchboka_edutools/directives/clear.py +103 -0
  8. munchboka_edutools/directives/dialogue.py +137 -0
  9. munchboka_edutools/directives/escape_room.py +296 -0
  10. munchboka_edutools/directives/factor_tree.py +549 -0
  11. munchboka_edutools/directives/ggb.py +209 -0
  12. munchboka_edutools/directives/ggb_icon.py +105 -0
  13. munchboka_edutools/directives/ggb_popup.py +165 -0
  14. munchboka_edutools/directives/horner.py +324 -0
  15. munchboka_edutools/directives/interactive_code.py +75 -0
  16. munchboka_edutools/directives/jeopardy.py +252 -0
  17. munchboka_edutools/directives/multi_plot.py +1126 -0
  18. munchboka_edutools/directives/pair_puzzle.py +191 -0
  19. munchboka_edutools/directives/parsons.py +109 -0
  20. munchboka_edutools/directives/plot.py +3105 -0
  21. munchboka_edutools/directives/poly_icon.py +111 -0
  22. munchboka_edutools/directives/polydiv.py +344 -0
  23. munchboka_edutools/directives/popup.py +245 -0
  24. munchboka_edutools/directives/quiz.py +291 -0
  25. munchboka_edutools/directives/signchart.py +516 -0
  26. munchboka_edutools/directives/timed_quiz.py +436 -0
  27. munchboka_edutools/directives/turtle.py +157 -0
  28. munchboka_edutools/static/css/admonitions.css +2012 -0
  29. munchboka_edutools/static/css/cas_popup.css +242 -0
  30. munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
  31. munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
  32. munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
  33. munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
  34. munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
  35. munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
  36. munchboka_edutools/static/css/dialogue.css +92 -0
  37. munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
  38. munchboka_edutools/static/css/figures.css +274 -0
  39. munchboka_edutools/static/css/general_style.css +74 -0
  40. munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
  41. munchboka_edutools/static/css/github-dark.css +112 -0
  42. munchboka_edutools/static/css/github-light.css +120 -0
  43. munchboka_edutools/static/css/interactive_code/style.css +575 -0
  44. munchboka_edutools/static/css/interactive_code.css +582 -0
  45. munchboka_edutools/static/css/jeopardy.css +529 -0
  46. munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
  47. munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
  48. munchboka_edutools/static/css/popup.css +115 -0
  49. munchboka_edutools/static/css/quiz.css +312 -0
  50. munchboka_edutools/static/css/timedQuiz.css +375 -0
  51. munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
  52. munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
  53. munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
  54. munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
  55. munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
  56. munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
  57. munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
  58. munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
  59. munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
  60. munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
  61. munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
  62. munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
  63. munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
  64. munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
  65. munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
  66. munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
  67. munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
  68. munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
  69. munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
  70. munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
  71. munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
  72. munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
  73. munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
  74. munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
  75. munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
  76. munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
  77. munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
  78. munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
  79. munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
  80. munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
  81. munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
  82. munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
  83. munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
  84. munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
  85. munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
  86. munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
  87. munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
  88. munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
  89. munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
  90. munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
  91. munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
  92. munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
  93. munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
  94. munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
  95. munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
  96. munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
  97. munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
  98. munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
  99. munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
  100. munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
  101. munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
  102. munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
  103. munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
  104. munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
  105. munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
  106. munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
  107. munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
  108. munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
  109. munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
  110. munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
  111. munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
  112. munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
  113. munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
  114. munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
  115. munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
  116. munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
  117. munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
  118. munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
  119. munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
  120. munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
  121. munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
  122. munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
  123. munchboka_edutools/static/icons/stickers/edit.svg +1 -0
  124. munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
  125. munchboka_edutools/static/js/casThemeManager.js +99 -0
  126. munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
  127. munchboka_edutools/static/js/geogebra-setup.js +6 -0
  128. munchboka_edutools/static/js/highlight-init.js +6 -0
  129. munchboka_edutools/static/js/interactiveCode/codeEditor.js +632 -0
  130. munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +348 -0
  131. munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
  132. munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
  133. munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
  134. munchboka_edutools/static/js/jeopardy.js +523 -0
  135. munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
  136. munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
  137. munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
  138. munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
  139. munchboka_edutools/static/js/popup.js +85 -0
  140. munchboka_edutools/static/js/quiz.js +422 -0
  141. munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
  142. munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
  143. munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
  144. munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
  145. munchboka_edutools/static/js/utils.js +3 -0
  146. munchboka_edutools-0.1.13.dist-info/METADATA +108 -0
  147. munchboka_edutools-0.1.13.dist-info/RECORD +149 -0
  148. munchboka_edutools-0.1.13.dist-info/WHEEL +4 -0
  149. munchboka_edutools-0.1.13.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1126 @@
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`` – 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
+ * ``vlines`` / ``hlines`` – vertical / horizontal reference lines.
187
+ * ``xlims`` / ``ylims`` – per-axis limits.
188
+ * ``lines`` – reference lines y = a*x + b or derived from basepoint.
189
+ * ``fn_labels`` / ``function-names`` – labels.
190
+
191
+ Meta / Control:
192
+ * ``nocache`` – force regeneration.
193
+ * ``debug`` – keep raw SVG + skip ID rewriting.
194
+ * ``name`` – explicit figure base name (influences cache filename).
195
+
196
+ Caption
197
+ -------
198
+ Any content after the parsed key/value block (or after inline key/value lines)
199
+ is treated as the caption and wrapped in a Sphinx ``<caption>`` node.
200
+
201
+ Implementation Notes
202
+ --------------------
203
+ * Expressions are compiled once per build variant and cached.
204
+ * Domain exclusions create widened gaps (± a few samples) for visual clarity.
205
+ * ID rewriting avoids collisions of gradients / clips when multiple multi-plot
206
+ images exist on the same page.
207
+ * Inline width styling is injected only once by a regex match on the first
208
+ ``<svg>`` tag; subsequent styling merges if a style attribute already exists.
209
+
210
+ This directive is specifically tuned for the pedagogical needs of the material
211
+ in this repository; tweak or extend as needed. If you add new options, please
212
+ update this docstring to keep the self-documenting pattern intact.
213
+ """
214
+
215
+ from __future__ import annotations
216
+
217
+ import os
218
+ import re
219
+ import uuid
220
+ import hashlib
221
+ import shutil
222
+ from typing import Callable, Dict, Any, Tuple, List
223
+
224
+ from docutils import nodes
225
+ from docutils.parsers.rst import directives
226
+ from sphinx.util.docutils import SphinxDirective
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Helpers
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def _hash_key(*parts) -> str:
234
+ h = hashlib.sha1()
235
+ for p in parts:
236
+ if p is None:
237
+ p = "__NONE__"
238
+ h.update(str(p).encode("utf-8"))
239
+ h.update(b"||")
240
+ return h.hexdigest()[:12]
241
+
242
+
243
+ def _compile_function(expr: str) -> Callable:
244
+ import sympy, numpy as np # local import
245
+
246
+ expr = expr.strip()
247
+ x = sympy.symbols("x")
248
+ try:
249
+ sym = sympy.sympify(expr)
250
+ except Exception as e: # pragma: no cover - user error path
251
+ raise ValueError(f"Ugyldig funksjonsuttrykk '{expr}': {e}")
252
+ fn_np = sympy.lambdify(x, sym, modules=["numpy"])
253
+
254
+ def f(arr):
255
+ return fn_np(np.asarray(arr, dtype=float))
256
+
257
+ # simple vectorization test
258
+ _ = f([0.0, 1.0])
259
+ return f
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Directive
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ def _strip_root_svg_size(svg_text: str) -> str:
268
+ """Remove width/height only on the root <svg> tag for responsiveness."""
269
+
270
+ def repl(m):
271
+ tag = m.group(0)
272
+ tag = re.sub(r'\swidth="[^"]+"', "", tag)
273
+ tag = re.sub(r'\sheight="[^"]+"', "", tag)
274
+ return tag
275
+
276
+ return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
277
+
278
+
279
+ def _parse_bool(val, default: bool | None = None) -> bool | None:
280
+ if val is None:
281
+ return default
282
+ if isinstance(val, bool):
283
+ return val
284
+ s = str(val).strip().lower()
285
+ if s == "": # option present with no value => treat as True
286
+ return True
287
+ if s in {"true", "yes", "on", "1"}:
288
+ return True
289
+ if s in {"false", "no", "off", "0"}:
290
+ return False
291
+ return default
292
+
293
+
294
+ def _split_expr_list(val: str) -> List[str]:
295
+ if not isinstance(val, str):
296
+ return []
297
+ s = val.strip()
298
+ if not s:
299
+ return []
300
+ # allow [a,b,c] or a;b;c or a,b,c
301
+ if s.startswith("[") and s.endswith("]"):
302
+ s = s[1:-1]
303
+ s = s.replace(";", ",")
304
+ parts = [p.strip() for p in s.split(",")]
305
+ return [p for p in parts if p]
306
+
307
+
308
+ def _split_top_level(val: str) -> List[str]:
309
+ """Split by commas at top level only (ignores commas inside (), [], {})."""
310
+ if not isinstance(val, str):
311
+ return []
312
+ s = val.strip()
313
+ if not s:
314
+ return []
315
+ # Strip surrounding brackets if present
316
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
317
+ s = s[1:-1].strip()
318
+ out: List[str] = []
319
+ cur = []
320
+ depth = 0
321
+ pairs = {")": "(", "]": "[", "}": "{"}
322
+ stack: List[str] = []
323
+ i = 0
324
+ while i < len(s):
325
+ ch = s[i]
326
+ if ch in "([{":
327
+ depth += 1
328
+ stack.append(ch)
329
+ cur.append(ch)
330
+ elif ch in ")]}":
331
+ depth = max(0, depth - 1)
332
+ if stack:
333
+ stack.pop()
334
+ cur.append(ch)
335
+ elif ch == "," and depth == 0:
336
+ part = "".join(cur).strip()
337
+ if part:
338
+ out.append(part)
339
+ cur = []
340
+ else:
341
+ cur.append(ch)
342
+ i += 1
343
+ tail = "".join(cur).strip()
344
+ if tail:
345
+ out.append(tail)
346
+ return out
347
+
348
+
349
+ def _safe_literal(val: str):
350
+ try:
351
+ import ast as _ast
352
+
353
+ return _ast.literal_eval(val)
354
+ except Exception:
355
+ return None
356
+
357
+
358
+ def _parse_values_list_or_none(s: str):
359
+ """Parse a scalar number or a tuple/list of numbers; return list[float] or None."""
360
+ if not isinstance(s, str):
361
+ return None
362
+ st = s.strip()
363
+ if not st or st.lower() == "none":
364
+ return None
365
+ lit = _safe_literal(st)
366
+ if isinstance(lit, (int, float)):
367
+ try:
368
+ return [float(lit)]
369
+ except Exception:
370
+ return None
371
+ if isinstance(lit, (list, tuple)):
372
+ out: List[float] = []
373
+ for v in lit:
374
+ try:
375
+ out.append(float(v))
376
+ except Exception:
377
+ pass
378
+ return out if out else None
379
+ # fallback: split by commas
380
+ parts = [p.strip() for p in st.split(",") if p.strip()]
381
+ out2: List[float] = []
382
+ for p in parts:
383
+ try:
384
+ out2.append(float(p))
385
+ except Exception:
386
+ return None
387
+ return out2 if out2 else None
388
+
389
+
390
+ class MultiPlotDirective(SphinxDirective):
391
+ has_content = True
392
+ required_arguments = 0
393
+ option_spec = {
394
+ "functions": directives.unchanged_required, # list of expressions
395
+ "fn_labels": directives.unchanged, # optional list of labels
396
+ "function-names": directives.unchanged, # alias for fn_labels in examples
397
+ "domains": directives.unchanged, # per-function domain (a,b) or (a,b) \ {..}
398
+ "vlines": directives.unchanged, # per-function vline x or None
399
+ "hlines": directives.unchanged, # per-function hline y or None
400
+ "xlims": directives.unchanged, # per-function xlim tuple or None
401
+ "ylims": directives.unchanged, # per-function ylim tuple or None
402
+ "lines": directives.unchanged, # per-axis line spec: (a,b) or (a,(x,y)) or None
403
+ "points": directives.unchanged, # per-axis point lists: [(x,y),(x,y)] or None
404
+ "xmin": directives.unchanged,
405
+ "xmax": directives.unchanged,
406
+ "ymin": directives.unchanged,
407
+ "ymax": directives.unchanged,
408
+ "xstep": directives.unchanged,
409
+ "ystep": directives.unchanged,
410
+ "fontsize": directives.unchanged,
411
+ "lw": directives.unchanged,
412
+ "alpha": directives.unchanged,
413
+ "grid": directives.unchanged,
414
+ "ticks": directives.unchanged,
415
+ "rows": directives.unchanged,
416
+ "cols": directives.unchanged,
417
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
418
+ "class": directives.class_option,
419
+ "name": directives.unchanged,
420
+ "nocache": directives.flag,
421
+ "alt": directives.unchanged,
422
+ "width": directives.length_or_percentage_or_unitless,
423
+ "debug": directives.flag,
424
+ }
425
+
426
+ # -----------------------------
427
+ # Content key/value parsing
428
+ # -----------------------------
429
+ def _gather_kv_from_content(self) -> Tuple[Dict[str, str], int]:
430
+ kv: Dict[str, str] = {}
431
+ lines = list(self.content)
432
+ idx = 0
433
+ # YAML front matter style
434
+ if lines and lines[0].strip() == "---":
435
+ idx = 1
436
+ while idx < len(lines) and lines[idx].strip() != "---":
437
+ line = lines[idx].rstrip()
438
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
439
+ if m:
440
+ kv[m.group(1)] = m.group(2)
441
+ idx += 1
442
+ if idx < len(lines) and lines[idx].strip() == "---":
443
+ idx += 1
444
+ while idx < len(lines) and not lines[idx].strip():
445
+ idx += 1
446
+ return kv, idx
447
+ # flat key: value lines until first non-matching or blank separation
448
+ caption_start = 0
449
+ for i, line in enumerate(lines):
450
+ if not line.strip():
451
+ caption_start = i + 1
452
+ continue
453
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
454
+ if m:
455
+ kv[m.group(1)] = m.group(2)
456
+ caption_start = i + 1
457
+ else:
458
+ break
459
+ return kv, caption_start
460
+
461
+ # -----------------------------
462
+ # Main run
463
+ # -----------------------------
464
+ def run(self): # noqa: C901 (complexity OK for directive)
465
+ env = self.state.document.settings.env
466
+ app = env.app
467
+ try:
468
+ import plotmath # type: ignore
469
+ except Exception as e: # pragma: no cover - dependency missing
470
+ err = nodes.error()
471
+ err += nodes.paragraph(text=f"Kunne ikke importere plotmath: {e}")
472
+ return [err]
473
+
474
+ kv, caption_idx = self._gather_kv_from_content()
475
+ merged: Dict[str, Any] = {**kv, **self.options}
476
+ if "functions" not in merged:
477
+ return [
478
+ self.state_machine.reporter.error(
479
+ "Directive 'multi-plot' krever 'functions:'", line=self.lineno
480
+ )
481
+ ]
482
+
483
+ exprs = _split_expr_list(str(merged["functions"]))
484
+ if not exprs:
485
+ return [
486
+ self.state_machine.reporter.error(
487
+ "'functions' var tomt eller ugyldig.", line=self.lineno
488
+ )
489
+ ]
490
+ # compile all
491
+ functions: List[Callable] = []
492
+ for e in exprs:
493
+ try:
494
+ functions.append(_compile_function(e))
495
+ except Exception as ex:
496
+ return [
497
+ self.state_machine.reporter.error(
498
+ f"Ugyldig funksjon '{e}': {ex}", line=self.lineno
499
+ )
500
+ ]
501
+
502
+ def _f(name, default):
503
+ v = merged.get(name)
504
+ if v in (None, ""):
505
+ return default
506
+ try:
507
+ return float(v)
508
+ except Exception:
509
+ return default
510
+
511
+ xmin = _f("xmin", -6)
512
+ xmax = _f("xmax", 6)
513
+ ymin = _f("ymin", -6)
514
+ ymax = _f("ymax", 6)
515
+ xstep = _f("xstep", 1)
516
+ ystep = _f("ystep", 1)
517
+ fontsize = _f("fontsize", 20)
518
+ lw = _f("lw", 2.5)
519
+ alpha_raw = merged.get("alpha")
520
+ grid_flag = _parse_bool(merged.get("grid"), default=True)
521
+ ticks_flag = _parse_bool(merged.get("ticks"), default=True)
522
+
523
+ try:
524
+ alpha = float(alpha_raw) if alpha_raw not in (None, "") else None
525
+ except Exception:
526
+ alpha = None
527
+
528
+ # Accept both fn_labels and function-names; function-names takes precedence if provided
529
+ labels_list: List[str] = _split_expr_list(
530
+ str(merged.get("function-names", merged.get("fn_labels", "")))
531
+ )
532
+ if labels_list and len(labels_list) == len(functions):
533
+ labels_arg: Any = labels_list
534
+ else:
535
+ labels_arg = True
536
+
537
+ # Per-function domains with optional exclusions, vlines, hlines, and axis limits
538
+ # Helper to parse domain with optional set-difference exclusions
539
+ def _parse_domain_with_exclusions(s: str):
540
+ if not isinstance(s, str):
541
+ return None, []
542
+ s = s.strip()
543
+ if not s or s.lower() == "none":
544
+ return None, []
545
+ num_re = r"[+-]?\d+(?:\.\d+)?"
546
+ dom_ex_pat = re.compile(
547
+ rf"\(\s*({num_re})\s*,\s*({num_re})\s*\)\s*(?:\\\s*\{{\s*([^}}]*)\s*\}})?"
548
+ )
549
+ m = dom_ex_pat.search(s)
550
+ if not m:
551
+ return None, []
552
+ try:
553
+ d0 = float(m.group(1))
554
+ d1 = float(m.group(2))
555
+ dom = (d0, d1)
556
+ except Exception:
557
+ dom = None
558
+ excludes: List[float] = []
559
+ excl_str = m.group(3) if m.lastindex and m.lastindex >= 3 else None
560
+ if excl_str:
561
+ for tok in [t.strip() for t in excl_str.split(",") if t.strip()]:
562
+ try:
563
+ excludes.append(float(tok))
564
+ except Exception:
565
+ pass
566
+ return dom, excludes
567
+
568
+ def _parse_tuple_or_none(s: str):
569
+ if not isinstance(s, str):
570
+ return None
571
+ st = s.strip()
572
+ if not st or st.lower() == "none":
573
+ return None
574
+ lit = _safe_literal(st)
575
+ if isinstance(lit, (list, tuple)) and len(lit) == 2:
576
+ try:
577
+ return (float(lit[0]), float(lit[1]))
578
+ except Exception:
579
+ return None
580
+ m = re.match(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)", st)
581
+ if m:
582
+ try:
583
+ return (float(m.group(1)), float(m.group(2)))
584
+ except Exception:
585
+ return None
586
+ return None
587
+
588
+ def _parse_scalar_or_none(s: str):
589
+ if not isinstance(s, str):
590
+ return None
591
+ st = s.strip()
592
+ if not st or st.lower() == "none":
593
+ return None
594
+ try:
595
+ return float(st)
596
+ except Exception:
597
+ return None
598
+
599
+ domains_raw = _split_top_level(str(merged.get("domains", "")))
600
+ vlines_raw = _split_top_level(str(merged.get("vlines", "")))
601
+ hlines_raw = _split_top_level(str(merged.get("hlines", "")))
602
+ xlims_raw = _split_top_level(str(merged.get("xlims", "")))
603
+ ylims_raw = _split_top_level(str(merged.get("ylims", "")))
604
+ lines_raw = _split_top_level(str(merged.get("lines", "")))
605
+ points_raw = _split_top_level(str(merged.get("points", "")))
606
+
607
+ # Normalize sizes to match number of functions
608
+ n = len(functions)
609
+
610
+ def _pad(lst, fill="None"):
611
+ return lst + [fill] * max(0, n - len(lst))
612
+
613
+ domains_raw = _pad(domains_raw)
614
+ vlines_raw = _pad(vlines_raw)
615
+ hlines_raw = _pad(hlines_raw)
616
+ xlims_raw = _pad(xlims_raw)
617
+ ylims_raw = _pad(ylims_raw)
618
+ lines_raw = _pad(lines_raw)
619
+ points_raw = _pad(points_raw)
620
+
621
+ dom_list: List[Tuple[float, float] | None] = []
622
+ excl_list: List[List[float]] = []
623
+ for s in domains_raw[:n]:
624
+ dom, ex = _parse_domain_with_exclusions(s)
625
+ dom_list.append(dom)
626
+ excl_list.append(ex)
627
+
628
+ vline_vals: List[List[float] | None] = [
629
+ _parse_values_list_or_none(s) for s in vlines_raw[:n]
630
+ ]
631
+ hline_vals: List[List[float] | None] = [
632
+ _parse_values_list_or_none(s) for s in hlines_raw[:n]
633
+ ]
634
+ xlim_vals: List[Tuple[float, float] | None] = [
635
+ _parse_tuple_or_none(s) for s in xlims_raw[:n]
636
+ ]
637
+ ylim_vals: List[Tuple[float, float] | None] = [
638
+ _parse_tuple_or_none(s) for s in ylims_raw[:n]
639
+ ]
640
+
641
+ # Parse per-axis line specs
642
+ def _parse_line_spec(s: str):
643
+ if not isinstance(s, str):
644
+ return None
645
+ st = s.strip()
646
+ if not st or st.lower() == "none":
647
+ return None
648
+ lit = _safe_literal(st)
649
+ a_val = None
650
+ b_val = None
651
+ if isinstance(lit, (list, tuple)) and len(lit) >= 2:
652
+ try:
653
+ a_val = float(lit[0])
654
+ except Exception:
655
+ a_val = None
656
+ second = lit[1]
657
+ if isinstance(second, (list, tuple)) and len(second) == 2:
658
+ try:
659
+ x0p = float(second[0])
660
+ y0p = float(second[1])
661
+ if a_val is not None:
662
+ b_val = y0p - a_val * x0p
663
+ except Exception:
664
+ b_val = None
665
+ else:
666
+ try:
667
+ b_val = float(second)
668
+ except Exception:
669
+ b_val = None
670
+ if a_val is not None and b_val is not None:
671
+ return (a_val, b_val)
672
+ return None
673
+
674
+ line_specs: List[Tuple[float, float] | None] = [_parse_line_spec(s) for s in lines_raw[:n]]
675
+
676
+ # Parse per-axis points. Each entry can be:
677
+ # - "None" or empty => no points for that axis
678
+ # - a single tuple like (x,y)
679
+ # - a list/tuple of tuples: [(x1,y1),(x2,y2)] or ((x1,y1),(x2,y2))
680
+ # - a loose comma form: (x1,y1); (x2,y2)
681
+ def _parse_points_entry(s: str):
682
+ if not isinstance(s, str):
683
+ return None
684
+ st = s.strip()
685
+ if not st or st.lower() == "none":
686
+ return None
687
+ lit = _safe_literal(st)
688
+ points_list: List[Tuple[float, float]] = []
689
+
690
+ def _coerce_pair(obj):
691
+ try:
692
+ if (
693
+ isinstance(obj, (list, tuple))
694
+ and len(obj) == 2
695
+ and all(isinstance(v, (int, float)) for v in obj)
696
+ ):
697
+ return (float(obj[0]), float(obj[1]))
698
+ except Exception:
699
+ return None
700
+ return None
701
+
702
+ if isinstance(lit, (list, tuple)):
703
+ # Could be list of pairs or a single pair
704
+ if len(lit) == 2 and all(isinstance(v, (int, float)) for v in lit):
705
+ p = _coerce_pair(lit)
706
+ if p:
707
+ points_list.append(p)
708
+ else:
709
+ for item in lit:
710
+ p = _coerce_pair(item)
711
+ if p:
712
+ points_list.append(p)
713
+ return points_list or None
714
+ # Fallback: find all (x,y) pattern occurrences
715
+ import re as _re
716
+
717
+ matches = _re.findall(
718
+ r"\(\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*,\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*\)",
719
+ st,
720
+ )
721
+ for a, b in matches:
722
+ try:
723
+ points_list.append((float(a), float(b)))
724
+ except Exception:
725
+ pass
726
+ return points_list or None
727
+
728
+ points_vals: List[List[Tuple[float, float]] | None] = [
729
+ _parse_points_entry(s) for s in points_raw[:n]
730
+ ]
731
+ explicit_name = merged.get("name")
732
+ debug_mode = "debug" in merged
733
+ rows = int(float(merged.get("rows", 1)))
734
+ # If cols not provided, default to enough columns to fit all functions over the given rows
735
+ default_cols = max(1, (len(functions) + rows - 1) // max(1, rows))
736
+ cols = int(float(merged.get("cols", default_cols)))
737
+
738
+ # Include per-axis settings in the hash to prevent stale caches
739
+ content_hash = _hash_key(
740
+ "|".join(exprs),
741
+ "|".join(labels_list),
742
+ xmin,
743
+ xmax,
744
+ ymin,
745
+ ymax,
746
+ xstep,
747
+ ystep,
748
+ fontsize,
749
+ lw,
750
+ alpha,
751
+ rows,
752
+ cols,
753
+ int(grid_flag),
754
+ int(ticks_flag),
755
+ "|".join(["" if d is None else f"{d[0]},{d[1]}" for d in dom_list]),
756
+ "|".join(["|".join(map(str, exs)) if exs else "" for exs in excl_list]),
757
+ "|".join(["|".join(map(str, vs)) if vs else "" for vs in vline_vals]),
758
+ "|".join(["|".join(map(str, hs)) if hs else "" for hs in hline_vals]),
759
+ "|".join(["" if xl is None else f"{xl[0]},{xl[1]}" for xl in xlim_vals]),
760
+ "|".join(["" if yl is None else f"{yl[0]},{yl[1]}" for yl in ylim_vals]),
761
+ "|".join(["" if ls is None else f"{ls[0]},{ls[1]}" for ls in line_specs]),
762
+ "|".join(
763
+ [
764
+ "" if pv is None else ";".join([f"{p[0]},{p[1]}" for p in pv])
765
+ for pv in points_vals
766
+ ]
767
+ ),
768
+ )
769
+ base_name = explicit_name or f"multi_plot_{content_hash}"
770
+
771
+ rel_dir = os.path.join("_static", "multi_plot")
772
+ abs_dir = os.path.join(app.srcdir, rel_dir)
773
+ os.makedirs(abs_dir, exist_ok=True)
774
+ svg_name = f"{base_name}.svg"
775
+ abs_svg = os.path.join(abs_dir, svg_name)
776
+
777
+ regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
778
+ if regenerate:
779
+ try:
780
+ letters = [chr(i) for i in range(65, 65 + len(functions))]
781
+ # Create axes grid without auto-plotting functions
782
+ fig, axes = plotmath.multiplot(
783
+ functions=[],
784
+ fn_labels=False,
785
+ xmin=xmin,
786
+ xmax=xmax,
787
+ ymin=ymin,
788
+ ymax=ymax,
789
+ xstep=xstep,
790
+ ystep=ystep,
791
+ ticks=ticks_flag,
792
+ grid=grid_flag,
793
+ rows=rows,
794
+ cols=cols,
795
+ lw=lw,
796
+ alpha=alpha,
797
+ fontsize=fontsize,
798
+ figsize=(4.5 * cols, 3.5 * rows),
799
+ )
800
+ # Normalize axes to flat list
801
+ try:
802
+ import numpy as _np
803
+
804
+ axes_list = (
805
+ list(axes.flatten())
806
+ if hasattr(axes, "flatten")
807
+ else list(_np.array(axes).flatten())
808
+ )
809
+ except Exception:
810
+ axes_list = axes if isinstance(axes, (list, tuple)) else [axes]
811
+
812
+ # Ensure tick label font size matches provided fontsize (legend handled later)
813
+ try:
814
+ for _ax in axes_list:
815
+ try:
816
+ _ax.tick_params(labelsize=int(fontsize))
817
+ except Exception:
818
+ pass
819
+ except Exception:
820
+ pass
821
+
822
+ # Manual plotting per-axis
823
+ import numpy as np
824
+
825
+ for idx, (expr, fn) in enumerate(zip(exprs, functions)):
826
+ if idx >= len(axes_list):
827
+ break
828
+ ax = axes_list[idx]
829
+ # Per-axis domain
830
+ dom = dom_list[idx]
831
+ x0, x1 = dom if dom is not None else (xmin, xmax)
832
+ N = int(2**12)
833
+ x = np.linspace(x0, x1, N)
834
+ y = fn(x)
835
+ # Ensure float array and blank out non-finite values
836
+ y = np.asarray(y, dtype=float)
837
+ y[~np.isfinite(y)] = np.nan
838
+ # Robust exclusions: widen window and clear neighbors
839
+ exs = [e for e in excl_list[idx] if x0 < e < x1]
840
+ if exs and N > 1:
841
+ dx = (x1 - x0) / (N - 1)
842
+ w = max(4 * dx, 1e-6 * (1.0 + max(abs(e) for e in exs)))
843
+ for e in exs:
844
+ try:
845
+ mask = np.abs(x - e) <= w
846
+ if mask.any():
847
+ y[mask] = np.nan
848
+ j = int(np.argmin(np.abs(x - e)))
849
+ for k in (j - 2, j - 1, j, j + 1, j + 2):
850
+ if 0 <= k < y.size:
851
+ y[k] = np.nan
852
+ except Exception:
853
+ try:
854
+ j = int(np.argmin(np.abs(x - e)))
855
+ if 0 <= j < y.size:
856
+ y[j] = np.nan
857
+ except Exception:
858
+ pass
859
+ # Also break across steep jumps or extreme magnitudes
860
+ # Determine per-axis y-span preference: use provided ylim for this axis if any
861
+ if ylim_vals[idx] is not None:
862
+ y0_lim, y1_lim = ylim_vals[idx]
863
+ else:
864
+ y0_lim, y1_lim = ymin, ymax
865
+ try:
866
+ y_span = abs(float(y1_lim) - float(y0_lim))
867
+ except Exception:
868
+ y_span = np.nan
869
+ if not (isinstance(y_span, (int, float)) and y_span > 0):
870
+ finite_y = y[np.isfinite(y)]
871
+ if finite_y.size > 0:
872
+ y_span = float(np.nanmax(finite_y) - np.nanmin(finite_y))
873
+ if not (isinstance(y_span, (int, float)) and y_span > 0):
874
+ y_span = 1.0
875
+ finite_pair = np.isfinite(y[:-1]) & np.isfinite(y[1:])
876
+ jump_factor = 0.5
877
+ big_jump = finite_pair & (np.abs(y[1:] - y[:-1]) > (jump_factor * y_span))
878
+ if big_jump.any():
879
+ idx_break = np.where(big_jump)[0]
880
+ for i_b in idx_break:
881
+ if 0 <= i_b + 1 < y.size:
882
+ y[i_b + 1] = np.nan
883
+ mag_factor = 50.0
884
+ too_big = np.isfinite(y) & (np.abs(y) > (mag_factor * y_span))
885
+ if too_big.any():
886
+ y[too_big] = np.nan
887
+ lbl = labels_list[idx] if (labels_list and idx < len(labels_list)) else None
888
+ if lbl:
889
+ ax.plot(x, y, lw=lw, alpha=alpha, label=f"${lbl}$")
890
+ ax.legend(fontsize=int(fontsize))
891
+ else:
892
+ ax.plot(x, y, lw=lw, alpha=alpha)
893
+ # Plot per-axis points if provided
894
+ try:
895
+ pv = points_vals[idx]
896
+ if pv:
897
+ xs = [p[0] for p in pv]
898
+ ys = [p[1] for p in pv]
899
+ ax.plot(
900
+ xs,
901
+ ys,
902
+ linestyle="none",
903
+ marker="o",
904
+ markersize=max(4, min(12, int(fontsize) // 2)),
905
+ color="black",
906
+ alpha=0.8,
907
+ )
908
+ except Exception:
909
+ pass
910
+ # Optional line y = a*x + b per axis
911
+ if line_specs[idx] is not None:
912
+ try:
913
+ a_l, b_l = line_specs[idx] # type: ignore[misc]
914
+ # Use provided xlim for this axis if any; else global
915
+ if xlim_vals[idx] is not None:
916
+ x_min_line, x_max_line = xlim_vals[idx]
917
+ else:
918
+ x_min_line, x_max_line = xmin, xmax
919
+ x_line = np.array([float(x_min_line), float(x_max_line)], dtype=float)
920
+ y_line = a_l * x_line + b_l
921
+ ax.plot(
922
+ x_line,
923
+ y_line,
924
+ linestyle="--",
925
+ color=plotmath.COLORS.get("red"),
926
+ lw=lw,
927
+ alpha=alpha,
928
+ )
929
+ except Exception:
930
+ pass
931
+ # vlines / hlines (support multiple values per axis)
932
+ for xv in vline_vals[idx] or []:
933
+ try:
934
+ ax.axvline(
935
+ x=float(xv),
936
+ color=plotmath.COLORS.get("red"),
937
+ linestyle="--",
938
+ lw=lw,
939
+ )
940
+ except Exception:
941
+ pass
942
+ for yh in hline_vals[idx] or []:
943
+ try:
944
+ ax.axhline(
945
+ y=float(yh),
946
+ color=plotmath.COLORS.get("red"),
947
+ linestyle="--",
948
+ lw=lw,
949
+ )
950
+ except Exception:
951
+ pass
952
+ # x/ylims
953
+ if xlim_vals[idx] is not None:
954
+ ax.set_xlim(*xlim_vals[idx])
955
+ if ylim_vals[idx] is not None:
956
+ ax.set_ylim(*ylim_vals[idx])
957
+ # Save via the single Figure object
958
+ fig.savefig(abs_svg, format="svg", bbox_inches="tight", transparent=True)
959
+ # Also save a PDF sidecar for debugging comparisons (optional)
960
+ # try:
961
+ # fig.savefig(
962
+ # os.path.join(abs_dir, f"{base_name}.pdf"),
963
+ # format="pdf",
964
+ # bbox_inches="tight",
965
+ # transparent=True,
966
+ # )
967
+ # except Exception:
968
+ # pass
969
+ import matplotlib
970
+
971
+ matplotlib.pyplot.close(fig)
972
+ except Exception as e:
973
+ return [
974
+ self.state_machine.reporter.error(
975
+ f"Feil under generering av graf: {e}", line=self.lineno
976
+ )
977
+ ]
978
+
979
+ if not os.path.exists(abs_svg):
980
+ return [self.state_machine.reporter.error("multi-plot: SVG mangler.", line=self.lineno)]
981
+
982
+ env.note_dependency(abs_svg)
983
+ try: # copy to output _static
984
+ out_static = os.path.join(app.outdir, "_static", "multi_plot")
985
+ os.makedirs(out_static, exist_ok=True)
986
+ shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
987
+ except Exception:
988
+ pass
989
+
990
+ try:
991
+ raw_svg = open(abs_svg, "r", encoding="utf-8").read()
992
+ except Exception as e:
993
+ return [
994
+ self.state_machine.reporter.error(
995
+ f"graph inline: kunne ikke lese SVG: {e}", line=self.lineno
996
+ )
997
+ ]
998
+
999
+ if not debug_mode and "viewBox" in raw_svg:
1000
+ raw_svg = _strip_root_svg_size(raw_svg)
1001
+
1002
+ def _rewrite_ids(txt: str, prefix: str) -> str:
1003
+ # Collect ids
1004
+ ids = re.findall(r'\bid="([^"]+)"', txt)
1005
+ if not ids:
1006
+ return txt
1007
+ # Skip font glyphs to avoid disrupting text rendering
1008
+ skip_prefixes = (
1009
+ "DejaVu",
1010
+ "CM",
1011
+ "STIX",
1012
+ "Nimbus",
1013
+ "Bitstream",
1014
+ "Arial",
1015
+ "Times",
1016
+ "Helvetica",
1017
+ )
1018
+ mapping = {}
1019
+ for i in ids:
1020
+ if i.startswith(skip_prefixes):
1021
+ continue
1022
+ mapping[i] = f"{prefix}{i}"
1023
+ if not mapping:
1024
+ return txt
1025
+
1026
+ # Replace id definitions
1027
+ def repl_id(m: re.Match) -> str:
1028
+ old = m.group(1)
1029
+ new = mapping.get(old, old)
1030
+ return f'id="{new}"'
1031
+
1032
+ txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
1033
+
1034
+ # Replace url(#id) everywhere (attributes and styles)
1035
+ def repl_url(m: re.Match) -> str:
1036
+ old = m.group(1).strip()
1037
+ new = mapping.get(old, old)
1038
+ return f"url(#{new})"
1039
+
1040
+ txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
1041
+
1042
+ # Replace href/xlink:href references supporting both quote styles
1043
+ def repl_href(m: re.Match) -> str:
1044
+ attr = m.group(1)
1045
+ quote = m.group(2)
1046
+ old = m.group(3).strip()
1047
+ new = mapping.get(old, old)
1048
+ return f"{attr}={quote}#{new}{quote}"
1049
+
1050
+ txt = re.sub(
1051
+ r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2',
1052
+ repl_href,
1053
+ txt,
1054
+ )
1055
+ return txt
1056
+
1057
+ if not debug_mode:
1058
+ raw_svg = _rewrite_ids(raw_svg, f"mgr_{content_hash}_{uuid.uuid4().hex[:6]}_")
1059
+
1060
+ alt_default = f"Multiplot av {len(exprs)} funksjoner"
1061
+ alt = merged.get("alt", alt_default)
1062
+
1063
+ width_opt = merged.get("width")
1064
+ percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
1065
+
1066
+ def _augment(m):
1067
+ tag = m.group(0)
1068
+ if "class=" not in tag:
1069
+ tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
1070
+ else:
1071
+ tag = tag.replace('class="', 'class="graph-inline-svg ')
1072
+ if alt and "aria-label=" not in tag:
1073
+ tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
1074
+ if width_opt:
1075
+ if percent:
1076
+ wval = width_opt.strip()
1077
+ else:
1078
+ wval = width_opt.strip()
1079
+ if wval.isdigit():
1080
+ wval += "px"
1081
+ style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
1082
+ if "style=" in tag:
1083
+ tag = re.sub(
1084
+ r'style="([^"]*)"',
1085
+ lambda mm: f'style="{mm.group(1)}; {style_frag}"',
1086
+ tag,
1087
+ count=1,
1088
+ )
1089
+ else:
1090
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
1091
+ return tag
1092
+
1093
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
1094
+ # Intentionally do not inject a <title> element to avoid hover tooltips; accessibility
1095
+ # remains via role="img" and aria-label attributes. Add manually later if truly needed.
1096
+
1097
+ figure = nodes.figure()
1098
+ figure.setdefault("classes", []).extend(
1099
+ ["adaptive-figure", "multi-plot-figure", "no-click"]
1100
+ )
1101
+ raw_node = nodes.raw("", raw_svg, format="html")
1102
+ raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
1103
+ figure += raw_node
1104
+
1105
+ extra_classes = merged.get("class")
1106
+ if extra_classes:
1107
+ figure["classes"].extend(extra_classes)
1108
+ figure["align"] = merged.get("align", "center")
1109
+
1110
+ caption_lines = list(self.content)[caption_idx:]
1111
+ while caption_lines and not caption_lines[0].strip():
1112
+ caption_lines.pop(0)
1113
+ if caption_lines:
1114
+ caption = nodes.caption()
1115
+ caption += nodes.Text("\n".join(caption_lines))
1116
+ figure += caption
1117
+
1118
+ if explicit_name:
1119
+ self.add_name(figure)
1120
+ return [figure]
1121
+
1122
+
1123
+ def setup(app): # pragma: no cover
1124
+ app.add_directive("multi-plot", MultiPlotDirective)
1125
+ app.add_directive("multiplot", MultiPlotDirective) # Also register without hyphen
1126
+ return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}