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,519 @@
1
+ """
2
+ Sign Chart Directive for Munchboka Edutools
3
+ ==========================================
4
+
5
+ This directive generates sign charts (fortegnsskjema) for polynomial functions
6
+ using the external `signchart` package. Sign charts are visual representations
7
+ showing where a polynomial function is positive, negative, or zero.
8
+
9
+ Usage in MyST Markdown:
10
+ ```{signchart}
11
+ ---
12
+ function: x**2 - 4, f(x)
13
+ factors: true
14
+ width: 100%
15
+ ---
16
+ Optional caption text
17
+ ```
18
+
19
+ Dependencies:
20
+ - signchart: External Python package for generating sign charts
21
+ - matplotlib: Used internally by signchart
22
+
23
+ Features:
24
+ - Automatic polynomial factorization display
25
+ - Configurable width and alignment
26
+ - SVG output with theme-aware styling
27
+ - Caching for faster builds
28
+ - Accessible with aria-label support
29
+
30
+ Author: René Aasen (ported from matematikk_r1)
31
+ Date: November 2025
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import os
38
+ import re
39
+ import shutil
40
+ import uuid
41
+ from typing import Any, Dict, List, Tuple
42
+
43
+ from docutils import nodes
44
+ from docutils.parsers.rst import directives
45
+ from sphinx.util.docutils import SphinxDirective
46
+
47
+
48
+ # ------------------------------------
49
+ # Utilities
50
+ # ------------------------------------
51
+
52
+
53
+ def _hash_key(*parts) -> str:
54
+ """
55
+ Generate a short hash key from multiple parts.
56
+
57
+ Used for creating unique filenames based on function content.
58
+
59
+ Args:
60
+ *parts: Variable number of parts to hash
61
+
62
+ Returns:
63
+ str: 12-character hex hash
64
+ """
65
+ h = hashlib.sha1()
66
+ for p in parts:
67
+ if p is None:
68
+ p = "__NONE__"
69
+ h.update(str(p).encode("utf-8"))
70
+ h.update(b"||")
71
+ return h.hexdigest()[:12]
72
+
73
+
74
+ def _safe_literal(val: str):
75
+ """
76
+ Safely evaluate a string as a Python literal.
77
+
78
+ Args:
79
+ val: String to evaluate
80
+
81
+ Returns:
82
+ Evaluated value or None if evaluation fails
83
+ """
84
+ import ast
85
+
86
+ try:
87
+ return ast.literal_eval(val)
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def _parse_bool(val, default: bool | None = None) -> bool | None:
93
+ """
94
+ Parse a value as a boolean.
95
+
96
+ Args:
97
+ val: Value to parse (bool, str, or None)
98
+ default: Default value if parsing fails
99
+
100
+ Returns:
101
+ bool | None: Parsed boolean value or default
102
+ """
103
+ if val is None:
104
+ return default
105
+ if isinstance(val, bool):
106
+ return val
107
+ s = str(val).strip().lower()
108
+ if s == "":
109
+ return True
110
+ if s in {"true", "yes", "on", "1"}:
111
+ return True
112
+ if s in {"false", "no", "off", "0"}:
113
+ return False
114
+ return default
115
+
116
+
117
+ def _strip_root_svg_size(svg_text: str) -> str:
118
+ """
119
+ Remove width/height attributes from the root <svg> tag.
120
+
121
+ This allows CSS to control the SVG size.
122
+
123
+ Args:
124
+ svg_text: Raw SVG content
125
+
126
+ Returns:
127
+ str: SVG with width/height removed from root tag
128
+ """
129
+
130
+ def repl(m):
131
+ tag = m.group(0)
132
+ tag = re.sub(r'\swidth="[^"]+"', "", tag)
133
+ tag = re.sub(r'\sheight="[^"]+"', "", tag)
134
+ return tag
135
+
136
+ return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
137
+
138
+
139
+ def _rewrite_ids(txt: str, prefix: str) -> str:
140
+ """
141
+ Rewrite all id attributes in SVG to avoid conflicts.
142
+
143
+ When multiple SVGs are on the same page, id conflicts can cause
144
+ rendering issues. This function prefixes all ids with a unique prefix.
145
+
146
+ Args:
147
+ txt: SVG content
148
+ prefix: Prefix to add to all ids
149
+
150
+ Returns:
151
+ str: SVG with rewritten ids
152
+ """
153
+ ids = re.findall(r'\bid="([^"]+)"', txt)
154
+ if not ids:
155
+ return txt
156
+ skip_prefixes = (
157
+ "DejaVu",
158
+ "CM",
159
+ "STIX",
160
+ "Nimbus",
161
+ "Bitstream",
162
+ "Arial",
163
+ "Times",
164
+ "Helvetica",
165
+ )
166
+ mapping = {}
167
+ for i in ids:
168
+ if i.startswith(skip_prefixes):
169
+ continue
170
+ mapping[i] = f"{prefix}{i}"
171
+ if not mapping:
172
+ return txt
173
+
174
+ def repl_id(m: re.Match) -> str:
175
+ old = m.group(1)
176
+ new = mapping.get(old, old)
177
+ return f'id="{new}"'
178
+
179
+ txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
180
+
181
+ def repl_url(m: re.Match) -> str:
182
+ old = m.group(1).strip()
183
+ new = mapping.get(old, old)
184
+ return f"url(#{new})"
185
+
186
+ txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
187
+
188
+ def repl_href(m: re.Match) -> str:
189
+ attr = m.group(1)
190
+ quote = m.group(2)
191
+ old = m.group(3).strip()
192
+ new = mapping.get(old, old)
193
+ return f"{attr}={quote}#{new}{quote}"
194
+
195
+ txt = re.sub(r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2', repl_href, txt)
196
+ return txt
197
+
198
+
199
+ class SignChartDirective(SphinxDirective):
200
+ """
201
+ Sphinx directive for generating sign charts of polynomial functions.
202
+
203
+ This directive uses the `signchart` package to generate visual representations
204
+ showing where a polynomial function is positive, negative, or zero.
205
+
206
+ Options:
207
+ function (required): The polynomial expression and optional label
208
+ Format: "expression, label" or ("expression", "label")
209
+ Example: "x**2 - 4, f(x)"
210
+ factors (optional): Whether to show factored form (default: true)
211
+ xmin (optional): Minimum x-value for the domain (custom domain)
212
+ xmax (optional): Maximum x-value for the domain (custom domain)
213
+ width (optional): Width of the chart (e.g., "100%", "500px", "500")
214
+ align (optional): Alignment ("left", "center", "right")
215
+ class (optional): Additional CSS classes
216
+ name (optional): Reference name for the figure
217
+ nocache (optional): Force regeneration of the chart
218
+ debug (optional): Keep original SVG dimensions and ids
219
+ alt (optional): Alt text for accessibility (default: "Fortegnsskjema")
220
+
221
+ Example:
222
+ ```{signchart}
223
+ ---
224
+ function: x**2 - 4, f(x)
225
+ factors: true
226
+ width: 80%
227
+ ---
228
+ Sign chart for f(x) = x² - 4
229
+ ```
230
+
231
+ With custom domain:
232
+ ```{signchart}
233
+ ---
234
+ function: -3/2 * k**2 + 9/2, A'(k)
235
+ xmin: 0
236
+ xmax: 3
237
+ width: 100%
238
+ ---
239
+ Sign chart restricted to domain [0, 3]
240
+ ```
241
+ """
242
+
243
+ has_content = True
244
+ required_arguments = 0
245
+ option_spec = {
246
+ # presentation / misc
247
+ "width": directives.length_or_percentage_or_unitless,
248
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
249
+ "class": directives.class_option,
250
+ "name": directives.unchanged,
251
+ "nocache": directives.flag,
252
+ "debug": directives.flag,
253
+ "alt": directives.unchanged,
254
+ # specific options
255
+ "function": directives.unchanged_required, # e.g. "x**2 - 4, f(x)"
256
+ "factors": directives.unchanged, # default True
257
+ "xmin": directives.unchanged, # custom domain minimum
258
+ "xmax": directives.unchanged, # custom domain maximum
259
+ }
260
+
261
+ def _parse_kv_block(self) -> Tuple[Dict[str, Any], int]:
262
+ """
263
+ Parse YAML-style key-value block from directive content.
264
+
265
+ Supports two formats:
266
+ 1. YAML front-matter style with --- delimiters
267
+ 2. Simple key: value pairs at the start
268
+
269
+ Returns:
270
+ tuple: (dict of parsed options, index where caption starts)
271
+ """
272
+ lines = list(self.content)
273
+ scalars: Dict[str, Any] = {}
274
+ idx = 0
275
+ if lines and lines[0].strip() == "---":
276
+ idx = 1
277
+ while idx < len(lines) and lines[idx].strip() != "---":
278
+ line = lines[idx].rstrip()
279
+ if not line.strip():
280
+ idx += 1
281
+ continue
282
+ m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
283
+ if m:
284
+ scalars[m.group(1)] = m.group(2)
285
+ idx += 1
286
+ if idx < len(lines) and lines[idx].strip() == "---":
287
+ idx += 1
288
+ while idx < len(lines) and not lines[idx].strip():
289
+ idx += 1
290
+ return scalars, idx
291
+
292
+ caption_start = 0
293
+ for i, line in enumerate(lines):
294
+ if not line.strip():
295
+ caption_start = i + 1
296
+ continue
297
+ m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
298
+ if m:
299
+ scalars[m.group(1)] = m.group(2)
300
+ caption_start = i + 1
301
+ else:
302
+ break
303
+ return scalars, caption_start
304
+
305
+ def run(self): # noqa: C901
306
+ """
307
+ Generate the sign chart.
308
+
309
+ Returns:
310
+ list: List of docutils nodes (figure containing SVG)
311
+ """
312
+ env = self.state.document.settings.env
313
+ app = env.app
314
+ try:
315
+ import signchart # type: ignore
316
+ except Exception as e:
317
+ err = nodes.error()
318
+ err += nodes.paragraph(text=f"Could not import signchart: {e}")
319
+ return [err]
320
+
321
+ scalars, caption_idx = self._parse_kv_block()
322
+ merged: Dict[str, Any] = {**scalars, **self.options}
323
+
324
+ func_raw = merged.get("function")
325
+ if not func_raw:
326
+ return [
327
+ self.state_machine.reporter.error(
328
+ "Directive 'signchart' requires 'function:' option", line=self.lineno
329
+ )
330
+ ]
331
+
332
+ # Parse function as either (expr, label) literal or "expr, label"
333
+ f_expr = None
334
+ f_name = None
335
+ lit = _safe_literal(str(func_raw))
336
+ if isinstance(lit, (list, tuple)) and len(lit) >= 1:
337
+ f_expr = str(lit[0]).strip()
338
+ if len(lit) > 1:
339
+ f_name = str(lit[1]).strip() or None
340
+ else:
341
+ s = str(func_raw)
342
+ if "," in s:
343
+ expr, label = s.split(",", 1)
344
+ f_expr = expr.strip()
345
+ label = label.strip()
346
+ f_name = label or None
347
+ else:
348
+ f_expr = s.strip()
349
+ f_name = None
350
+
351
+ include_factors = _parse_bool(merged.get("factors"), default=True)
352
+ explicit_name = merged.get("name")
353
+ debug_mode = "debug" in merged
354
+
355
+ # Parse custom domain if provided
356
+ xmin_val = merged.get("xmin")
357
+ xmax_val = merged.get("xmax")
358
+ custom_domain = None
359
+ if xmin_val is not None and xmax_val is not None:
360
+ try:
361
+ xmin_float = float(xmin_val)
362
+ xmax_float = float(xmax_val)
363
+ custom_domain = (xmin_float, xmax_float)
364
+ except (ValueError, TypeError):
365
+ return [
366
+ self.state_machine.reporter.warning(
367
+ f"signchart: Could not parse xmin='{xmin_val}' and xmax='{xmax_val}' as floats. Ignoring domain.",
368
+ line=self.lineno,
369
+ )
370
+ ]
371
+
372
+ # Hash includes function, name, factors, and domain
373
+ content_hash = _hash_key(
374
+ f_expr,
375
+ f_name or "",
376
+ int(bool(include_factors)),
377
+ str(custom_domain) if custom_domain else "",
378
+ )
379
+ base_name = explicit_name or f"signchart_{content_hash}"
380
+
381
+ rel_dir = os.path.join("_static", "signchart")
382
+ abs_dir = os.path.join(app.srcdir, rel_dir)
383
+ os.makedirs(abs_dir, exist_ok=True)
384
+ svg_name = f"{base_name}.svg"
385
+ abs_svg = os.path.join(abs_dir, svg_name)
386
+
387
+ regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
388
+ if regenerate:
389
+ try:
390
+ # Render using signchart and save as SVG
391
+ plot_kwargs = {
392
+ "f": f_expr,
393
+ "fn_name": f_name or None,
394
+ "include_factors": bool(include_factors),
395
+ }
396
+ # Add domain if custom domain is specified
397
+ if custom_domain is not None:
398
+ plot_kwargs["domain"] = custom_domain
399
+
400
+ signchart.plot(**plot_kwargs)
401
+ signchart.savefig(dirname=abs_dir, fname=svg_name)
402
+ except Exception as e:
403
+ return [
404
+ self.state_machine.reporter.error(
405
+ f"Error generating sign chart: {e}",
406
+ line=self.lineno,
407
+ )
408
+ ]
409
+
410
+ if not os.path.exists(abs_svg):
411
+ return [
412
+ self.state_machine.reporter.error("signchart: SVG file missing.", line=self.lineno)
413
+ ]
414
+
415
+ env.note_dependency(abs_svg)
416
+ # copy into build _static
417
+ try:
418
+ out_static = os.path.join(app.outdir, "_static", "signchart")
419
+ os.makedirs(out_static, exist_ok=True)
420
+ shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
421
+ except Exception:
422
+ pass
423
+
424
+ try:
425
+ raw_svg = open(abs_svg, "r", encoding="utf-8").read()
426
+ except Exception as e:
427
+ return [
428
+ self.state_machine.reporter.error(
429
+ f"signchart inline: could not read SVG: {e}", line=self.lineno
430
+ )
431
+ ]
432
+
433
+ if not debug_mode and "viewBox" in raw_svg:
434
+ raw_svg = _strip_root_svg_size(raw_svg)
435
+
436
+ if not debug_mode:
437
+ raw_svg = _rewrite_ids(raw_svg, f"sgc_{content_hash}_{uuid.uuid4().hex[:6]}_")
438
+
439
+ alt_default = "Fortegnsskjema"
440
+ alt = merged.get("alt", alt_default)
441
+
442
+ width_opt = merged.get("width")
443
+ percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
444
+
445
+ def _augment(m):
446
+ """Add classes, aria-label, and width styling to root SVG tag."""
447
+ tag = m.group(0)
448
+ if "class=" not in tag:
449
+ tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
450
+ else:
451
+ tag = tag.replace('class="', 'class="graph-inline-svg ')
452
+ if alt and "aria-label=" not in tag:
453
+ tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
454
+ if width_opt:
455
+ if percent:
456
+ wval = width_opt.strip()
457
+ else:
458
+ wval = width_opt.strip()
459
+ if wval.isdigit():
460
+ wval += "px"
461
+ style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
462
+ if "style=" in tag:
463
+ tag = re.sub(
464
+ r'style="([^"]*)"',
465
+ lambda mm: f'style="{mm.group(1)}; {style_frag}"',
466
+ tag,
467
+ count=1,
468
+ )
469
+ else:
470
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
471
+ return tag
472
+
473
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
474
+ # Suppress automatic <title> insertion to avoid browser hover tooltips.
475
+ # Accessibility is maintained via role="img" and aria-label set above.
476
+
477
+ figure = nodes.figure()
478
+ figure.setdefault("classes", []).extend(["adaptive-figure", "signchart-figure", "no-click"])
479
+ raw_node = nodes.raw("", raw_svg, format="html")
480
+ raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
481
+ figure += raw_node
482
+
483
+ extra_classes = merged.get("class")
484
+ if extra_classes:
485
+ figure["classes"].extend(extra_classes)
486
+ figure["align"] = merged.get("align", "center")
487
+
488
+ caption_lines = list(self.content)[caption_idx:]
489
+ while caption_lines and not caption_lines[0].strip():
490
+ caption_lines.pop(0)
491
+ if caption_lines:
492
+ caption = nodes.caption()
493
+ caption_text = "\n".join(caption_lines)
494
+ # Parse as inline text to support math while avoiding extra paragraph nodes
495
+ parsed_nodes, messages = self.state.inline_text(caption_text, self.lineno)
496
+ caption.extend(parsed_nodes)
497
+ figure += caption
498
+
499
+ if explicit_name := merged.get("name"):
500
+ self.add_name(figure)
501
+ return [figure]
502
+
503
+
504
+ def setup(app):
505
+ """
506
+ Setup function to register the directive with Sphinx.
507
+
508
+ This function is called automatically by Sphinx when the extension is loaded.
509
+ It registers both 'signchart' and 'sign-chart' directives for compatibility.
510
+
511
+ Args:
512
+ app: The Sphinx application instance
513
+
514
+ Returns:
515
+ dict: Extension metadata including version and parallel processing flags
516
+ """
517
+ app.add_directive("signchart", SignChartDirective)
518
+ app.add_directive("sign-chart", SignChartDirective)
519
+ return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}