munchboka-edutools 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. munchboka_edutools/__init__.py +182 -0
  2. munchboka_edutools/_plotmath_shim.py +126 -0
  3. munchboka_edutools/_version.py +2 -0
  4. munchboka_edutools/directives/__init__.py +1 -0
  5. munchboka_edutools/directives/admonitions.py +356 -0
  6. munchboka_edutools/directives/cas_popup.py +272 -0
  7. munchboka_edutools/directives/dialogue.py +137 -0
  8. munchboka_edutools/directives/escape_room.py +296 -0
  9. munchboka_edutools/directives/factor_tree.py +549 -0
  10. munchboka_edutools/directives/ggb.py +209 -0
  11. munchboka_edutools/directives/ggb_icon.py +62 -0
  12. munchboka_edutools/directives/ggb_popup.py +165 -0
  13. munchboka_edutools/directives/horner.py +324 -0
  14. munchboka_edutools/directives/interactive_code.py +75 -0
  15. munchboka_edutools/directives/jeopardy.py +252 -0
  16. munchboka_edutools/directives/multi_plot.py +1126 -0
  17. munchboka_edutools/directives/pair_puzzle.py +191 -0
  18. munchboka_edutools/directives/parsons.py +109 -0
  19. munchboka_edutools/directives/plot.py +3012 -0
  20. munchboka_edutools/directives/poly_icon.py +91 -0
  21. munchboka_edutools/directives/polydiv.py +344 -0
  22. munchboka_edutools/directives/quiz.py +291 -0
  23. munchboka_edutools/directives/signchart.py +474 -0
  24. munchboka_edutools/directives/timed_quiz.py +436 -0
  25. munchboka_edutools/directives/turtle.py +157 -0
  26. munchboka_edutools/static/css/admonitions.css +2012 -0
  27. munchboka_edutools/static/css/cas_popup.css +242 -0
  28. munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
  29. munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
  30. munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
  31. munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
  32. munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
  33. munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
  34. munchboka_edutools/static/css/dialogue.css +92 -0
  35. munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
  36. munchboka_edutools/static/css/figures.css +274 -0
  37. munchboka_edutools/static/css/general_style.css +74 -0
  38. munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
  39. munchboka_edutools/static/css/github-dark.css +112 -0
  40. munchboka_edutools/static/css/github-light.css +120 -0
  41. munchboka_edutools/static/css/interactive_code/style.css +575 -0
  42. munchboka_edutools/static/css/interactive_code.css +582 -0
  43. munchboka_edutools/static/css/jeopardy.css +476 -0
  44. munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
  45. munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
  46. munchboka_edutools/static/css/quiz.css +312 -0
  47. munchboka_edutools/static/css/timedQuiz.css +375 -0
  48. munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
  49. munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
  50. munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
  51. munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
  52. munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
  53. munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
  54. munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
  55. munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
  56. munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
  57. munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
  58. munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
  59. munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
  60. munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
  61. munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
  62. munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
  63. munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
  64. munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
  65. munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
  66. munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
  67. munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
  68. munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
  69. munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
  70. munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
  71. munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
  72. munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
  73. munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
  74. munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
  75. munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
  76. munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
  77. munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
  78. munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
  79. munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
  80. munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
  81. munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
  82. munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
  83. munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
  84. munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
  85. munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
  86. munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
  87. munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
  88. munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
  89. munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
  90. munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
  91. munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
  92. munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
  93. munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
  94. munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
  95. munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
  96. munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
  97. munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
  98. munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
  99. munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
  100. munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
  101. munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
  102. munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
  103. munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
  104. munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
  105. munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
  106. munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
  107. munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
  108. munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
  109. munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
  110. munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
  111. munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
  112. munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
  113. munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
  114. munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
  115. munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
  116. munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
  117. munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
  118. munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
  119. munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
  120. munchboka_edutools/static/icons/stickers/edit.svg +1 -0
  121. munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
  122. munchboka_edutools/static/js/casThemeManager.js +99 -0
  123. munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
  124. munchboka_edutools/static/js/geogebra-setup.js +6 -0
  125. munchboka_edutools/static/js/highlight-init.js +6 -0
  126. munchboka_edutools/static/js/interactiveCode/codeEditor.js +632 -0
  127. munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +348 -0
  128. munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
  129. munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
  130. munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
  131. munchboka_edutools/static/js/interactive_code/codeEditor.js +662 -0
  132. munchboka_edutools/static/js/interactive_code/interactiveCodeSetup.js +252 -0
  133. munchboka_edutools/static/js/interactive_code/pythonRunner.js +145 -0
  134. munchboka_edutools/static/js/interactive_code/turtleCode.js +56 -0
  135. munchboka_edutools/static/js/interactive_code/workerManager.js +204 -0
  136. munchboka_edutools/static/js/jeopardy.js +457 -0
  137. munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
  138. munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
  139. munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
  140. munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
  141. munchboka_edutools/static/js/quiz.js +422 -0
  142. munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
  143. munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
  144. munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
  145. munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
  146. munchboka_edutools/static/js/utils.js +3 -0
  147. munchboka_edutools-0.1.0.dist-info/METADATA +107 -0
  148. munchboka_edutools-0.1.0.dist-info/RECORD +150 -0
  149. munchboka_edutools-0.1.0.dist-info/WHEEL +4 -0
  150. munchboka_edutools-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,474 @@
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
+ width (optional): Width of the chart (e.g., "100%", "500px", "500")
212
+ align (optional): Alignment ("left", "center", "right")
213
+ class (optional): Additional CSS classes
214
+ name (optional): Reference name for the figure
215
+ nocache (optional): Force regeneration of the chart
216
+ debug (optional): Keep original SVG dimensions and ids
217
+ alt (optional): Alt text for accessibility (default: "Fortegnsskjema")
218
+
219
+ Example:
220
+ ```{signchart}
221
+ ---
222
+ function: x**2 - 4, f(x)
223
+ factors: true
224
+ width: 80%
225
+ ---
226
+ Sign chart for f(x) = x² - 4
227
+ ```
228
+ """
229
+
230
+ has_content = True
231
+ required_arguments = 0
232
+ option_spec = {
233
+ # presentation / misc
234
+ "width": directives.length_or_percentage_or_unitless,
235
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
236
+ "class": directives.class_option,
237
+ "name": directives.unchanged,
238
+ "nocache": directives.flag,
239
+ "debug": directives.flag,
240
+ "alt": directives.unchanged,
241
+ # specific options
242
+ "function": directives.unchanged_required, # e.g. "x**2 - 4, f(x)"
243
+ "factors": directives.unchanged, # default True
244
+ }
245
+
246
+ def _parse_kv_block(self) -> Tuple[Dict[str, Any], int]:
247
+ """
248
+ Parse YAML-style key-value block from directive content.
249
+
250
+ Supports two formats:
251
+ 1. YAML front-matter style with --- delimiters
252
+ 2. Simple key: value pairs at the start
253
+
254
+ Returns:
255
+ tuple: (dict of parsed options, index where caption starts)
256
+ """
257
+ lines = list(self.content)
258
+ scalars: Dict[str, Any] = {}
259
+ idx = 0
260
+ if lines and lines[0].strip() == "---":
261
+ idx = 1
262
+ while idx < len(lines) and lines[idx].strip() != "---":
263
+ line = lines[idx].rstrip()
264
+ if not line.strip():
265
+ idx += 1
266
+ continue
267
+ m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
268
+ if m:
269
+ scalars[m.group(1)] = m.group(2)
270
+ idx += 1
271
+ if idx < len(lines) and lines[idx].strip() == "---":
272
+ idx += 1
273
+ while idx < len(lines) and not lines[idx].strip():
274
+ idx += 1
275
+ return scalars, idx
276
+
277
+ caption_start = 0
278
+ for i, line in enumerate(lines):
279
+ if not line.strip():
280
+ caption_start = i + 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
+ caption_start = i + 1
286
+ else:
287
+ break
288
+ return scalars, caption_start
289
+
290
+ def run(self): # noqa: C901
291
+ """
292
+ Generate the sign chart.
293
+
294
+ Returns:
295
+ list: List of docutils nodes (figure containing SVG)
296
+ """
297
+ env = self.state.document.settings.env
298
+ app = env.app
299
+ try:
300
+ import signchart # type: ignore
301
+ except Exception as e:
302
+ err = nodes.error()
303
+ err += nodes.paragraph(text=f"Could not import signchart: {e}")
304
+ return [err]
305
+
306
+ scalars, caption_idx = self._parse_kv_block()
307
+ merged: Dict[str, Any] = {**scalars, **self.options}
308
+
309
+ func_raw = merged.get("function")
310
+ if not func_raw:
311
+ return [
312
+ self.state_machine.reporter.error(
313
+ "Directive 'signchart' requires 'function:' option", line=self.lineno
314
+ )
315
+ ]
316
+
317
+ # Parse function as either (expr, label) literal or "expr, label"
318
+ f_expr = None
319
+ f_name = None
320
+ lit = _safe_literal(str(func_raw))
321
+ if isinstance(lit, (list, tuple)) and len(lit) >= 1:
322
+ f_expr = str(lit[0]).strip()
323
+ if len(lit) > 1:
324
+ f_name = str(lit[1]).strip() or None
325
+ else:
326
+ s = str(func_raw)
327
+ if "," in s:
328
+ expr, label = s.split(",", 1)
329
+ f_expr = expr.strip()
330
+ label = label.strip()
331
+ f_name = label or None
332
+ else:
333
+ f_expr = s.strip()
334
+ f_name = None
335
+
336
+ include_factors = _parse_bool(merged.get("factors"), default=True)
337
+ explicit_name = merged.get("name")
338
+ debug_mode = "debug" in merged
339
+
340
+ # Hash includes function, name, and factors
341
+ content_hash = _hash_key(f_expr, f_name or "", int(bool(include_factors)))
342
+ base_name = explicit_name or f"signchart_{content_hash}"
343
+
344
+ rel_dir = os.path.join("_static", "signchart")
345
+ abs_dir = os.path.join(app.srcdir, rel_dir)
346
+ os.makedirs(abs_dir, exist_ok=True)
347
+ svg_name = f"{base_name}.svg"
348
+ abs_svg = os.path.join(abs_dir, svg_name)
349
+
350
+ regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
351
+ if regenerate:
352
+ try:
353
+ # Render using signchart and save as SVG
354
+ signchart.plot(
355
+ f=f_expr,
356
+ fn_name=f_name or None,
357
+ include_factors=bool(include_factors),
358
+ )
359
+ signchart.savefig(dirname=abs_dir, fname=svg_name)
360
+ except Exception as e:
361
+ return [
362
+ self.state_machine.reporter.error(
363
+ f"Error generating sign chart: {e}",
364
+ line=self.lineno,
365
+ )
366
+ ]
367
+
368
+ if not os.path.exists(abs_svg):
369
+ return [
370
+ self.state_machine.reporter.error("signchart: SVG file missing.", line=self.lineno)
371
+ ]
372
+
373
+ env.note_dependency(abs_svg)
374
+ # copy into build _static
375
+ try:
376
+ out_static = os.path.join(app.outdir, "_static", "signchart")
377
+ os.makedirs(out_static, exist_ok=True)
378
+ shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
379
+ except Exception:
380
+ pass
381
+
382
+ try:
383
+ raw_svg = open(abs_svg, "r", encoding="utf-8").read()
384
+ except Exception as e:
385
+ return [
386
+ self.state_machine.reporter.error(
387
+ f"signchart inline: could not read SVG: {e}", line=self.lineno
388
+ )
389
+ ]
390
+
391
+ if not debug_mode and "viewBox" in raw_svg:
392
+ raw_svg = _strip_root_svg_size(raw_svg)
393
+
394
+ if not debug_mode:
395
+ raw_svg = _rewrite_ids(raw_svg, f"sgc_{content_hash}_{uuid.uuid4().hex[:6]}_")
396
+
397
+ alt_default = "Fortegnsskjema"
398
+ alt = merged.get("alt", alt_default)
399
+
400
+ width_opt = merged.get("width")
401
+ percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
402
+
403
+ def _augment(m):
404
+ """Add classes, aria-label, and width styling to root SVG tag."""
405
+ tag = m.group(0)
406
+ if "class=" not in tag:
407
+ tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
408
+ else:
409
+ tag = tag.replace('class="', 'class="graph-inline-svg ')
410
+ if alt and "aria-label=" not in tag:
411
+ tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
412
+ if width_opt:
413
+ if percent:
414
+ wval = width_opt.strip()
415
+ else:
416
+ wval = width_opt.strip()
417
+ if wval.isdigit():
418
+ wval += "px"
419
+ style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
420
+ if "style=" in tag:
421
+ tag = re.sub(
422
+ r'style="([^"]*)"',
423
+ lambda mm: f'style="{mm.group(1)}; {style_frag}"',
424
+ tag,
425
+ count=1,
426
+ )
427
+ else:
428
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
429
+ return tag
430
+
431
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
432
+ # Suppress automatic <title> insertion to avoid browser hover tooltips.
433
+ # Accessibility is maintained via role="img" and aria-label set above.
434
+
435
+ figure = nodes.figure()
436
+ figure.setdefault("classes", []).extend(["adaptive-figure", "signchart-figure", "no-click"])
437
+ raw_node = nodes.raw("", raw_svg, format="html")
438
+ raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
439
+ figure += raw_node
440
+
441
+ extra_classes = merged.get("class")
442
+ if extra_classes:
443
+ figure["classes"].extend(extra_classes)
444
+ figure["align"] = merged.get("align", "center")
445
+
446
+ caption_lines = list(self.content)[caption_idx:]
447
+ while caption_lines and not caption_lines[0].strip():
448
+ caption_lines.pop(0)
449
+ if caption_lines:
450
+ caption = nodes.caption()
451
+ caption += nodes.Text("\n".join(caption_lines))
452
+ figure += caption
453
+
454
+ if explicit_name := merged.get("name"):
455
+ self.add_name(figure)
456
+ return [figure]
457
+
458
+
459
+ def setup(app):
460
+ """
461
+ Setup function to register the directive with Sphinx.
462
+
463
+ This function is called automatically by Sphinx when the extension is loaded.
464
+ It registers both 'signchart' and 'sign-chart' directives for compatibility.
465
+
466
+ Args:
467
+ app: The Sphinx application instance
468
+
469
+ Returns:
470
+ dict: Extension metadata including version and parallel processing flags
471
+ """
472
+ app.add_directive("signchart", SignChartDirective)
473
+ app.add_directive("sign-chart", SignChartDirective)
474
+ return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}