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.
- munchboka_edutools/__init__.py +184 -0
- munchboka_edutools/_plotmath_shim.py +126 -0
- munchboka_edutools/_version.py +2 -0
- munchboka_edutools/directives/__init__.py +1 -0
- munchboka_edutools/directives/admonitions.py +389 -0
- munchboka_edutools/directives/cas_popup.py +272 -0
- munchboka_edutools/directives/clear.py +103 -0
- munchboka_edutools/directives/dialogue.py +137 -0
- munchboka_edutools/directives/escape_room.py +296 -0
- munchboka_edutools/directives/factor_tree.py +549 -0
- munchboka_edutools/directives/ggb.py +209 -0
- munchboka_edutools/directives/ggb_icon.py +105 -0
- munchboka_edutools/directives/ggb_popup.py +165 -0
- munchboka_edutools/directives/horner.py +324 -0
- munchboka_edutools/directives/interactive_code.py +75 -0
- munchboka_edutools/directives/jeopardy.py +252 -0
- munchboka_edutools/directives/multi_plot.py +1126 -0
- munchboka_edutools/directives/pair_puzzle.py +191 -0
- munchboka_edutools/directives/parsons.py +109 -0
- munchboka_edutools/directives/plot.py +3105 -0
- munchboka_edutools/directives/poly_icon.py +111 -0
- munchboka_edutools/directives/polydiv.py +344 -0
- munchboka_edutools/directives/popup.py +245 -0
- munchboka_edutools/directives/quiz.py +291 -0
- munchboka_edutools/directives/signchart.py +516 -0
- munchboka_edutools/directives/timed_quiz.py +436 -0
- munchboka_edutools/directives/turtle.py +157 -0
- munchboka_edutools/static/css/admonitions.css +2012 -0
- munchboka_edutools/static/css/cas_popup.css +242 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
- munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
- munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
- munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
- munchboka_edutools/static/css/dialogue.css +92 -0
- munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
- munchboka_edutools/static/css/figures.css +274 -0
- munchboka_edutools/static/css/general_style.css +74 -0
- munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
- munchboka_edutools/static/css/github-dark.css +112 -0
- munchboka_edutools/static/css/github-light.css +120 -0
- munchboka_edutools/static/css/interactive_code/style.css +575 -0
- munchboka_edutools/static/css/interactive_code.css +582 -0
- munchboka_edutools/static/css/jeopardy.css +529 -0
- munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
- munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
- munchboka_edutools/static/css/popup.css +115 -0
- munchboka_edutools/static/css/quiz.css +312 -0
- munchboka_edutools/static/css/timedQuiz.css +375 -0
- munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
- munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
- munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
- munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
- munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
- munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
- munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
- munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
- munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
- munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
- munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
- munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
- munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
- munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/stickers/edit.svg +1 -0
- munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
- munchboka_edutools/static/js/casThemeManager.js +99 -0
- munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
- munchboka_edutools/static/js/geogebra-setup.js +6 -0
- munchboka_edutools/static/js/highlight-init.js +6 -0
- munchboka_edutools/static/js/interactiveCode/codeEditor.js +632 -0
- munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +348 -0
- munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
- munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
- munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
- munchboka_edutools/static/js/jeopardy.js +523 -0
- munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
- munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
- munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
- munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
- munchboka_edutools/static/js/popup.js +85 -0
- munchboka_edutools/static/js/quiz.js +422 -0
- munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
- munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
- munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
- munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
- munchboka_edutools/static/js/utils.js +3 -0
- munchboka_edutools-0.1.13.dist-info/METADATA +108 -0
- munchboka_edutools-0.1.13.dist-info/RECORD +149 -0
- munchboka_edutools-0.1.13.dist-info/WHEEL +4 -0
- 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}
|