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,549 @@
1
+ """
2
+ Factor Tree Directive
3
+ =====================
4
+
5
+ A Sphinx directive that generates visual factor trees (prime factorization trees) for integers.
6
+ Uses matplotlib to create SVG visualizations showing the step-by-step breakdown of a number
7
+ into its prime factors.
8
+
9
+ Features:
10
+ - Automatic prime factorization
11
+ - Customizable tree appearance (angle, branch length, font size)
12
+ - Configurable figure sizing
13
+ - Content-based caching to avoid regeneration
14
+ - Accessibility support with ARIA labels
15
+ - Caption support
16
+
17
+ Dependencies:
18
+ - matplotlib (for plotting)
19
+ - numpy (for calculations)
20
+
21
+ Usage Examples:
22
+ --------------
23
+
24
+ Basic usage (factor tree for 68):
25
+ ```
26
+ .. factor-tree::
27
+ :n: 68
28
+ ```
29
+
30
+ Custom styling:
31
+ ```
32
+ .. factor-tree::
33
+ :n: 120
34
+ :angle: 40
35
+ :branch_length: 2.0
36
+ :fontsize: 20
37
+ :width: 400px
38
+ ```
39
+
40
+ With figure sizing:
41
+ ```
42
+ .. factor-tree::
43
+ :n: 84
44
+ :figsize: 4,4
45
+ :width: 80%
46
+ :align: center
47
+
48
+ Factor tree for 84
49
+ ```
50
+
51
+ YAML-style configuration:
52
+ ```
53
+ .. factor-tree::
54
+ ---
55
+ n: 96
56
+ angle: 35
57
+ fontsize: 22
58
+ ---
59
+
60
+ Prime factorization of 96
61
+ ```
62
+
63
+ Author: Original implementation from matematikk_r1
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import os
69
+ import re
70
+ import shutil
71
+ import uuid
72
+ import html
73
+ from typing import Any, Dict, List, Tuple
74
+
75
+ from docutils import nodes
76
+ from docutils.parsers.rst import directives
77
+ from sphinx.util.docutils import SphinxDirective
78
+
79
+
80
+ # --------------------
81
+ # Utilities
82
+ # --------------------
83
+ import hashlib
84
+
85
+
86
+ def _hash_key(*parts) -> str:
87
+ """Generate a hash key from multiple parts for caching."""
88
+ h = hashlib.sha1()
89
+ for p in parts:
90
+ if p is None:
91
+ p = "__NONE__"
92
+ h.update(str(p).encode("utf-8"))
93
+ h.update(b"||")
94
+ return h.hexdigest()[:12]
95
+
96
+
97
+ def _strip_root_svg_size(svg_text: str) -> str:
98
+ """Remove width/height attributes from root <svg> tag to make it responsive."""
99
+
100
+ def repl(m):
101
+ tag = m.group(0)
102
+ tag = re.sub(r'\swidth="[^"]+"', "", tag)
103
+ tag = re.sub(r'\sheight="[^"]+"', "", tag)
104
+ return tag
105
+
106
+ return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
107
+
108
+
109
+ def _rewrite_ids(txt: str, prefix: str) -> str:
110
+ """
111
+ Rewrite IDs in SVG to avoid collisions when multiple SVGs are on the same page.
112
+ Skips font-related IDs.
113
+ """
114
+ ids = re.findall(r'\bid="([^"]+)"', txt)
115
+ if not ids:
116
+ return txt
117
+ skip_prefixes = (
118
+ "DejaVu",
119
+ "CM",
120
+ "STIX",
121
+ "Nimbus",
122
+ "Bitstream",
123
+ "Arial",
124
+ "Times",
125
+ "Helvetica",
126
+ )
127
+ mapping = {}
128
+ for i in ids:
129
+ if i.startswith(skip_prefixes):
130
+ continue
131
+ mapping[i] = f"{prefix}{i}"
132
+ if not mapping:
133
+ return txt
134
+
135
+ def repl_id(m: re.Match) -> str:
136
+ old = m.group(1)
137
+ new = mapping.get(old, old)
138
+ return f'id="{new}"'
139
+
140
+ txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
141
+
142
+ def repl_url(m: re.Match) -> str:
143
+ old = m.group(1).strip()
144
+ new = mapping.get(old, old)
145
+ return f"url(#{new})"
146
+
147
+ txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
148
+
149
+ def repl_href(m: re.Match) -> str:
150
+ attr = m.group(1)
151
+ quote = m.group(2)
152
+ old = m.group(3).strip()
153
+ new = mapping.get(old, old)
154
+ return f"{attr}={quote}#{new}{quote}"
155
+
156
+ txt = re.sub(r'(xlink:href|href)\s*=\s*(["\'])#\s*([^"\']+)\s*\2', repl_href, txt)
157
+ return txt
158
+
159
+
160
+ class FactorTreeDirective(SphinxDirective):
161
+ """
162
+ Directive to create a visual factor tree for an integer.
163
+
164
+ Options:
165
+ :n: The integer to factorize (default: 68)
166
+ :angle: Angle between branches in degrees (default: 30)
167
+ :branch_length: Length of branches (default: 1.8)
168
+ :fontsize: Font size for numbers (default: 18)
169
+ :figsize: Figure size as "w,h" or "[w,h]" (e.g., "4,4")
170
+ :figwidth: Figure width in inches
171
+ :figheight: Figure height in inches
172
+ :width: Display width (e.g., "400px" or "80%")
173
+ :align: Alignment - left, center, or right (default: center)
174
+ :class: Additional CSS classes
175
+ :name: Explicit name for cross-referencing
176
+ :nocache: Force regeneration of the figure
177
+ :debug: Keep original SVG size attributes
178
+ :alt: Alternative text for accessibility
179
+ """
180
+
181
+ has_content = True
182
+ required_arguments = 0
183
+ option_spec = {
184
+ # presentation
185
+ "width": directives.length_or_percentage_or_unitless,
186
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
187
+ "class": directives.class_option,
188
+ "name": directives.unchanged,
189
+ "nocache": directives.flag,
190
+ "debug": directives.flag,
191
+ "alt": directives.unchanged,
192
+ # parameters
193
+ "n": directives.unchanged,
194
+ "angle": directives.unchanged, # degrees
195
+ "branch_length": directives.unchanged,
196
+ "fontsize": directives.unchanged,
197
+ # figure sizing
198
+ "figsize": directives.unchanged, # "w,h" or "[w,h]" or "(w,h)"
199
+ "figwidth": directives.unchanged,
200
+ "figheight": directives.unchanged,
201
+ }
202
+
203
+ def _parse_kv_block(self) -> Tuple[Dict[str, Any], int]:
204
+ """Parse YAML-style key-value block or simple key: value lines."""
205
+ lines = list(self.content)
206
+ scalars: Dict[str, Any] = {}
207
+ if lines and lines[0].strip() == "---":
208
+ idx = 1
209
+ while idx < len(lines) and lines[idx].strip() != "---":
210
+ line = lines[idx].rstrip()
211
+ if not line.strip():
212
+ idx += 1
213
+ continue
214
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
215
+ if m:
216
+ key, value = m.group(1), m.group(2)
217
+ scalars[key] = value
218
+ idx += 1
219
+ if idx < len(lines) and lines[idx].strip() == "---":
220
+ idx += 1
221
+ while idx < len(lines) and not lines[idx].strip():
222
+ idx += 1
223
+ return scalars, idx
224
+ # fallback: simple key: value lines at top
225
+ caption_start = 0
226
+ for i, line in enumerate(lines):
227
+ if not line.strip():
228
+ caption_start = i + 1
229
+ continue
230
+ m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
231
+ if m:
232
+ key, value = m.group(1), m.group(2)
233
+ scalars[key] = value
234
+ caption_start = i + 1
235
+ else:
236
+ break
237
+ return scalars, caption_start
238
+
239
+ def run(self):
240
+ env = self.state.document.settings.env
241
+ app = env.app
242
+ try:
243
+ import matplotlib
244
+ import matplotlib.pyplot as plt
245
+ import numpy as np # noqa: F401
246
+ except Exception as e:
247
+ err = nodes.error()
248
+ err += nodes.paragraph(text=f"Kunne ikke importere nødvendige biblioteker: {e}")
249
+ return [err]
250
+
251
+ scalars, caption_idx = self._parse_kv_block()
252
+ merged: Dict[str, Any] = {**scalars, **self.options}
253
+
254
+ def _f_float(name: str, default: float) -> float:
255
+ v = merged.get(name)
256
+ if v in (None, ""):
257
+ return default
258
+ try:
259
+ return float(v)
260
+ except Exception:
261
+ return default
262
+
263
+ def _f_int(name: str, default: int) -> int:
264
+ v = merged.get(name)
265
+ if v in (None, ""):
266
+ return default
267
+ try:
268
+ return int(float(v))
269
+ except Exception:
270
+ return default
271
+
272
+ def _f_float_opt(name: str) -> float | None:
273
+ v = merged.get(name)
274
+ if v in (None, ""):
275
+ return None
276
+ try:
277
+ return float(v)
278
+ except Exception:
279
+ return None
280
+
281
+ def _parse_figsize(val: str | None) -> Tuple[float, float] | None:
282
+ if not isinstance(val, str) or not val.strip():
283
+ return None
284
+ s = val.strip()
285
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
286
+ s = s[1:-1].strip()
287
+ parts = [p.strip() for p in s.split(",") if p.strip()]
288
+ if len(parts) >= 2:
289
+ try:
290
+ w = float(parts[0])
291
+ h = float(parts[1])
292
+ return (w, h)
293
+ except Exception:
294
+ return None
295
+ return None
296
+
297
+ n = _f_int("n", 68)
298
+ angle = _f_float("angle", 30.0)
299
+ branch_len = _f_float("branch_length", 1.8)
300
+ fontsize = _f_int("fontsize", 18)
301
+
302
+ explicit_name = merged.get("name")
303
+ debug_mode = "debug" in merged # noqa: F841 (reserved for future use)
304
+
305
+ content_hash = _hash_key(n, angle, branch_len, fontsize)
306
+ base_name = explicit_name or f"factor_tree_{content_hash}"
307
+
308
+ rel_dir = os.path.join("_static", "factor_tree")
309
+ abs_dir = os.path.join(app.srcdir, rel_dir)
310
+ os.makedirs(abs_dir, exist_ok=True)
311
+ svg_name = f"{base_name}.svg"
312
+ abs_svg = os.path.join(abs_dir, svg_name)
313
+
314
+ regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
315
+ if regenerate:
316
+ matplotlib.use("Agg")
317
+ try:
318
+ # Build the tree and plot
319
+ import matplotlib as mpl
320
+
321
+ # Keep mathtext only (no LaTeX dependency); fontsize configurable
322
+ mpl.rcParams["text.usetex"] = False
323
+ mpl.rcParams["font.size"] = fontsize
324
+
325
+ def prime_factors(x: int) -> List[int]:
326
+ """Get list of prime factors in order."""
327
+ for i in range(2, x + 1):
328
+ if x % i == 0:
329
+ if i == x:
330
+ return [x]
331
+ return [i] + prime_factors(x // i)
332
+
333
+ def build_tree(x: int):
334
+ """Build tree structure as (value, [children])."""
335
+ facs = prime_factors(x)
336
+ if len(facs) == 1:
337
+ return (x, [])
338
+ left = facs[0]
339
+ right = x // left
340
+ return (x, [build_tree(left), build_tree(right)])
341
+
342
+ def get_tree_depth(node) -> int:
343
+ """Get maximum depth of tree."""
344
+ if not node[1]:
345
+ return 1
346
+ return 1 + max(get_tree_depth(child) for child in node[1])
347
+
348
+ def plot_tree(node, x=0.0, y=0.0, ax=None, level=0, max_depth=None):
349
+ """Recursively plot the factor tree."""
350
+ if ax is None:
351
+ fig, ax = plt.subplots()
352
+ ax.axis("off")
353
+ ax.set_aspect("equal")
354
+
355
+ import numpy as _np
356
+
357
+ angle_rad = _np.radians(angle)
358
+ dx = branch_len * _np.sin(angle_rad)
359
+ dy = -branch_len * _np.cos(angle_rad)
360
+
361
+ value, children = node
362
+ # Use a hardcoded blue color instead of plotmath.COLORS
363
+ blue_color = (0.0, 0.447, 0.698) # matplotlib RGB tuple
364
+
365
+ ax.text(
366
+ x,
367
+ y,
368
+ f"${value}$",
369
+ ha="center",
370
+ va="center",
371
+ bbox=dict(
372
+ boxstyle="circle,pad=0.3",
373
+ facecolor="#e0f7fa",
374
+ edgecolor=blue_color,
375
+ linewidth=1.5,
376
+ ),
377
+ )
378
+
379
+ if children:
380
+ offsets = [-dx, dx]
381
+ for child, offset_x in zip(children, offsets):
382
+ child_x = x + offset_x
383
+ child_y = y + dy
384
+ ax.plot(
385
+ [x, child_x],
386
+ [y, child_y],
387
+ color="#455a64",
388
+ linewidth=1.2,
389
+ )
390
+ plot_tree(
391
+ child,
392
+ x=child_x,
393
+ y=child_y,
394
+ ax=ax,
395
+ level=level + 1,
396
+ max_depth=max_depth,
397
+ )
398
+
399
+ tree = build_tree(n)
400
+ depth = get_tree_depth(tree)
401
+ plot_tree(tree, max_depth=depth)
402
+
403
+ # Expand limits to include text bbox circles and add margins
404
+ ax = plt.gca()
405
+ try:
406
+ ax.relim()
407
+ ax.autoscale_view()
408
+ ax.margins(x=0.3, y=0.4)
409
+ except Exception:
410
+ pass
411
+
412
+ fig = plt.gcf()
413
+ # Figure size: from figsize, or figwidth/figheight, else default 3x3 in
414
+ fs = _parse_figsize(merged.get("figsize"))
415
+ fw = _f_float_opt("figwidth")
416
+ fh = _f_float_opt("figheight")
417
+ if fs is not None:
418
+ fig.set_size_inches(fs[0], fs[1])
419
+ elif fw is not None or fh is not None:
420
+ fig.set_size_inches(
421
+ fw if fw is not None else 3.0, fh if fh is not None else 3.0
422
+ )
423
+ else:
424
+ fig.set_size_inches(3, 3)
425
+
426
+ plt.tight_layout(pad=0.6)
427
+
428
+ fig.savefig(
429
+ abs_svg,
430
+ format="svg",
431
+ bbox_inches="tight",
432
+ transparent=True,
433
+ pad_inches=0.1,
434
+ )
435
+ import matplotlib.pyplot as _plt
436
+
437
+ _plt.close(fig)
438
+ except Exception as e:
439
+ return [
440
+ self.state_machine.reporter.error(
441
+ f"Feil under generering av faktor-tre: {e}", line=self.lineno
442
+ )
443
+ ]
444
+
445
+ if not os.path.exists(abs_svg):
446
+ return [
447
+ self.state_machine.reporter.error("factor-tree: SVG mangler.", line=self.lineno)
448
+ ]
449
+
450
+ env.note_dependency(abs_svg)
451
+ # copy into build _static
452
+ try:
453
+ out_static = os.path.join(app.outdir, "_static", "factor_tree")
454
+ os.makedirs(out_static, exist_ok=True)
455
+ shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
456
+ except Exception:
457
+ pass
458
+
459
+ try:
460
+ raw_svg = open(abs_svg, "r", encoding="utf-8").read()
461
+ except Exception as e:
462
+ return [
463
+ self.state_machine.reporter.error(
464
+ f"factor-tree inline: kunne ikke lese SVG: {e}", line=self.lineno
465
+ )
466
+ ]
467
+
468
+ if "debug" not in merged and "viewBox" in raw_svg:
469
+ raw_svg = _strip_root_svg_size(raw_svg)
470
+
471
+ if "debug" not in merged:
472
+ raw_svg = _rewrite_ids(raw_svg, f"ft_{content_hash}_{uuid.uuid4().hex[:6]}_")
473
+
474
+ alt = merged.get("alt", f"Faktor-tre for {n}")
475
+ width_opt = merged.get("width")
476
+ align_raw = merged.get("align", "center")
477
+ align_opt = str(align_raw).strip().lower() if isinstance(align_raw, str) else "center"
478
+ if align_opt not in {"left", "center", "right"}:
479
+ align_opt = "center"
480
+ percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
481
+
482
+ alt_attr = html.escape(alt, quote=True) if isinstance(alt, str) else ""
483
+ alt_title = html.escape(alt, quote=False) if isinstance(alt, str) else ""
484
+
485
+ def _augment(m):
486
+ tag = m.group(0)
487
+ if "class=" not in tag:
488
+ tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
489
+ else:
490
+ tag = tag.replace('class="', 'class="graph-inline-svg ')
491
+ if alt and "aria-label=" not in tag:
492
+ tag = tag[:-1] + f' role="img" aria-label="{alt_attr}"' + ">"
493
+ if width_opt:
494
+ if percent:
495
+ wval = width_opt.strip()
496
+ else:
497
+ wval = width_opt.strip()
498
+ if wval.isdigit():
499
+ wval += "px"
500
+ # Align-aware margins to avoid overriding figure alignment
501
+ if align_opt == "left":
502
+ margin = "margin-left:0; margin-right:auto;"
503
+ elif align_opt == "right":
504
+ margin = "margin-left:auto; margin-right:0;"
505
+ else:
506
+ margin = "margin-left:auto; margin-right:auto;"
507
+ style_frag = f"width:{wval}; height:auto; display:block; {margin}"
508
+ if "style=" in tag:
509
+ tag = re.sub(
510
+ r'style="([^"]*)"',
511
+ lambda mm: f'style="{mm.group(1)}; {style_frag}"',
512
+ tag,
513
+ count=1,
514
+ )
515
+ else:
516
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
517
+ return tag
518
+
519
+ # Correct pattern ensures augmentation executes
520
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
521
+
522
+ figure = nodes.figure()
523
+ figure.setdefault("classes", []).extend(["adaptive-figure", "plot-figure", "no-click"])
524
+ raw_node = nodes.raw("", raw_svg, format="html")
525
+ raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
526
+ figure += raw_node
527
+
528
+ extra_classes = merged.get("class")
529
+ if extra_classes:
530
+ figure["classes"].extend(extra_classes)
531
+ figure["align"] = align_opt
532
+
533
+ caption_lines = list(self.content)[caption_idx:]
534
+ while caption_lines and not caption_lines[0].strip():
535
+ caption_lines.pop(0)
536
+ if caption_lines:
537
+ caption = nodes.caption()
538
+ caption += nodes.Text("\n".join(caption_lines))
539
+ figure += caption
540
+
541
+ if explicit_name:
542
+ self.add_name(figure)
543
+ return [figure]
544
+
545
+
546
+ def setup(app):
547
+ """Register the directive with Sphinx."""
548
+ app.add_directive("factor-tree", FactorTreeDirective)
549
+ return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}