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,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Horner scheme (synthetic division) directive for Sphinx/Jupyter Book.
|
|
3
|
+
|
|
4
|
+
Generates SVG visualizations of Horner's method/synthetic division using LaTeX.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from docutils import nodes
|
|
13
|
+
from docutils.parsers.rst import directives
|
|
14
|
+
from sphinx.util.docutils import SphinxDirective
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _hash_key(*parts):
|
|
18
|
+
"""Generate a hash key from multiple parts for caching."""
|
|
19
|
+
h = hashlib.sha1()
|
|
20
|
+
for p in parts:
|
|
21
|
+
if p is None:
|
|
22
|
+
p = "__NONE__"
|
|
23
|
+
h.update(str(p).encode("utf-8"))
|
|
24
|
+
h.update(b"||")
|
|
25
|
+
return h.hexdigest()[:12]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def synthetic_div(
|
|
29
|
+
fname: str,
|
|
30
|
+
p: str,
|
|
31
|
+
x: float,
|
|
32
|
+
stage: int = 12,
|
|
33
|
+
svg: bool = True,
|
|
34
|
+
tutor: bool = False,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Generate Horner scheme (synthetic division) figure using LaTeX.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
fname: Base filename (without extension)
|
|
41
|
+
p: Polynomial as string (e.g., "x^3 + 2x^2 - 3x - 6")
|
|
42
|
+
x: Value to evaluate at
|
|
43
|
+
stage: Stage number for step-by-step display (default: 12 = complete)
|
|
44
|
+
svg: If True, convert to SVG; otherwise keep as PDF
|
|
45
|
+
tutor: If True, enable tutor mode with step-by-step guidance
|
|
46
|
+
"""
|
|
47
|
+
if not tutor:
|
|
48
|
+
div_cmd = r"\polyhornerscheme[x={x}, resultstyle=\color{red}, showvar=true]{{p}}"
|
|
49
|
+
div_cmd = div_cmd.replace("{p}", p).replace("{x}", str(x))
|
|
50
|
+
else:
|
|
51
|
+
div_cmd = r"\polyhornerscheme[x={x}, stage={stage}, tutor=true, tutorlimit=12, resultstyle=\color{red}, showvar=true]{{p}}"
|
|
52
|
+
div_cmd = div_cmd.replace("{p}", p).replace("{x}", str(x)).replace("{stage}", str(stage))
|
|
53
|
+
|
|
54
|
+
s = f"""\\documentclass{{standalone}}
|
|
55
|
+
\\usepackage{{polynom}}
|
|
56
|
+
\\usepackage{{xcolor}}
|
|
57
|
+
\\begin{{document}}
|
|
58
|
+
{div_cmd}
|
|
59
|
+
\\end{{document}}
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
with open("tmp.tex", "w") as f:
|
|
63
|
+
f.write(s)
|
|
64
|
+
|
|
65
|
+
os.system("pdflatex tmp.tex")
|
|
66
|
+
if fname.endswith(".svg"):
|
|
67
|
+
fname = fname.strip(".svg")
|
|
68
|
+
|
|
69
|
+
if svg:
|
|
70
|
+
os.system(f"pdf2svg tmp.pdf {fname}.svg")
|
|
71
|
+
else:
|
|
72
|
+
os.system(f"mv tmp.pdf {fname}.pdf")
|
|
73
|
+
|
|
74
|
+
os.system("rm tmp.*")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HornerDirective(SphinxDirective):
|
|
78
|
+
"""
|
|
79
|
+
Generate (and cache) a Horner (synthetic division) scheme as inline SVG.
|
|
80
|
+
|
|
81
|
+
Usage (MyST):
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
::::{horner}
|
|
85
|
+
:p: x^3 + 2x^2 - 3x - 6
|
|
86
|
+
:x: 1
|
|
87
|
+
:stage: 2 # optional
|
|
88
|
+
:width: 60% # optional
|
|
89
|
+
|
|
90
|
+
Optional caption here.
|
|
91
|
+
::::
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or classic reStructuredText:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
.. horner::
|
|
98
|
+
:p: x^3 + 2x^2 - 3x - 6
|
|
99
|
+
:x: 1
|
|
100
|
+
:stage: 2
|
|
101
|
+
|
|
102
|
+
Optional caption here.
|
|
103
|
+
```
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
has_content = True
|
|
107
|
+
required_arguments = 0
|
|
108
|
+
optional_arguments = 0
|
|
109
|
+
final_argument_whitespace = False
|
|
110
|
+
option_spec = {
|
|
111
|
+
"p": directives.unchanged_required,
|
|
112
|
+
"x": directives.unchanged_required,
|
|
113
|
+
"stage": directives.nonnegative_int,
|
|
114
|
+
"tutor": directives.flag,
|
|
115
|
+
"align": lambda a: directives.choice(a, ["left", "center", "right"]),
|
|
116
|
+
"class": directives.class_option,
|
|
117
|
+
"name": directives.unchanged,
|
|
118
|
+
"nocache": directives.flag,
|
|
119
|
+
"alt": directives.unchanged,
|
|
120
|
+
"width": directives.length_or_percentage_or_unitless,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def run(self):
|
|
124
|
+
env = self.state.document.settings.env
|
|
125
|
+
app = env.app
|
|
126
|
+
|
|
127
|
+
# Required options
|
|
128
|
+
p = self.options.get("p")
|
|
129
|
+
x_val = self.options.get("x")
|
|
130
|
+
if p is None or x_val is None:
|
|
131
|
+
return [
|
|
132
|
+
self.state_machine.reporter.error(
|
|
133
|
+
"Directive 'horner' requires both :p: and :x: options.",
|
|
134
|
+
line=self.lineno,
|
|
135
|
+
)
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
# Optional options
|
|
139
|
+
stage = self.options.get("stage", 12)
|
|
140
|
+
tutor_mode = "tutor" in self.options
|
|
141
|
+
explicit_name = self.options.get("name")
|
|
142
|
+
|
|
143
|
+
# Cache key must include tutor mode so variants don't collide
|
|
144
|
+
content_hash = _hash_key(p, x_val, stage, int(tutor_mode))
|
|
145
|
+
base_name = explicit_name or f"horner_{content_hash}"
|
|
146
|
+
|
|
147
|
+
# Paths / caching
|
|
148
|
+
src_dir = app.srcdir
|
|
149
|
+
rel_dir = os.path.join("_static", "horner")
|
|
150
|
+
abs_dir = os.path.join(src_dir, rel_dir)
|
|
151
|
+
os.makedirs(abs_dir, exist_ok=True)
|
|
152
|
+
svg_filename = f"{base_name}.svg"
|
|
153
|
+
abs_svg_path = os.path.join(abs_dir, svg_filename)
|
|
154
|
+
|
|
155
|
+
regenerate = "nocache" in self.options or not os.path.exists(abs_svg_path)
|
|
156
|
+
if regenerate:
|
|
157
|
+
cwd = os.getcwd()
|
|
158
|
+
try:
|
|
159
|
+
os.chdir(abs_dir)
|
|
160
|
+
synthetic_div(
|
|
161
|
+
fname=base_name,
|
|
162
|
+
p=p,
|
|
163
|
+
x=x_val,
|
|
164
|
+
stage=stage,
|
|
165
|
+
svg=True,
|
|
166
|
+
tutor=tutor_mode,
|
|
167
|
+
)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return [
|
|
170
|
+
self.state_machine.reporter.error(
|
|
171
|
+
f"Error generating Horner scheme: {e}",
|
|
172
|
+
line=self.lineno,
|
|
173
|
+
)
|
|
174
|
+
]
|
|
175
|
+
finally:
|
|
176
|
+
os.chdir(cwd)
|
|
177
|
+
|
|
178
|
+
# Post-process: strip explicit width/height if viewBox present for responsiveness
|
|
179
|
+
try:
|
|
180
|
+
if os.path.exists(abs_svg_path):
|
|
181
|
+
with open(abs_svg_path, "r", encoding="utf-8") as f_svg:
|
|
182
|
+
raw_tmp = f_svg.read()
|
|
183
|
+
if "viewBox" in raw_tmp:
|
|
184
|
+
cleaned = re.sub(r'\swidth="[^"]+"', "", raw_tmp)
|
|
185
|
+
cleaned = re.sub(r'\sheight="[^"]+"', "", cleaned)
|
|
186
|
+
if cleaned != raw_tmp:
|
|
187
|
+
with open(abs_svg_path, "w", encoding="utf-8") as f_out:
|
|
188
|
+
f_out.write(cleaned)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
if not os.path.exists(abs_svg_path):
|
|
193
|
+
return [
|
|
194
|
+
self.state_machine.reporter.error(
|
|
195
|
+
(
|
|
196
|
+
f"horner: failed to generate SVG '{svg_filename}'. "
|
|
197
|
+
"Check that 'pdflatex' and 'pdf2svg' are installed."
|
|
198
|
+
),
|
|
199
|
+
line=self.lineno,
|
|
200
|
+
)
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
# Track dependency & copy to output for HTML builder
|
|
204
|
+
env.note_dependency(abs_svg_path)
|
|
205
|
+
try:
|
|
206
|
+
out_static = os.path.join(app.outdir, "_static", "horner")
|
|
207
|
+
os.makedirs(out_static, exist_ok=True)
|
|
208
|
+
shutil.copy2(abs_svg_path, os.path.join(out_static, svg_filename))
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
# Alt text construction
|
|
213
|
+
if stage is not None and stage != 12:
|
|
214
|
+
default_alt = f"Horner's scheme for ({p}) at x={x_val} – stage {stage}"
|
|
215
|
+
else:
|
|
216
|
+
default_alt = f"Horner's scheme for ({p}) at x={x_val}"
|
|
217
|
+
if tutor_mode:
|
|
218
|
+
default_alt += " (tutor mode)"
|
|
219
|
+
alt = self.options.get("alt", default_alt)
|
|
220
|
+
|
|
221
|
+
width_opt = self.options.get("width")
|
|
222
|
+
percentage_width = isinstance(width_opt, str) and width_opt.strip().endswith("%")
|
|
223
|
+
|
|
224
|
+
# Read generated SVG
|
|
225
|
+
try:
|
|
226
|
+
with open(abs_svg_path, "r", encoding="utf-8") as f_svg:
|
|
227
|
+
raw_svg = f_svg.read()
|
|
228
|
+
except Exception as e:
|
|
229
|
+
return [
|
|
230
|
+
self.state_machine.reporter.error(
|
|
231
|
+
f"horner inline: could not read SVG: {e}",
|
|
232
|
+
line=self.lineno,
|
|
233
|
+
)
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
# Ensure unique IDs per embedding
|
|
237
|
+
def _uniquify_ids(svg_text: str, prefix: str) -> str:
|
|
238
|
+
ids = set(re.findall(r'\bid="([^"]+)"', svg_text))
|
|
239
|
+
if not ids:
|
|
240
|
+
return svg_text
|
|
241
|
+
mapping = {old: f"{prefix}{old}" for old in ids}
|
|
242
|
+
for old, new in mapping.items():
|
|
243
|
+
svg_text = re.sub(rf'\bid="{re.escape(old)}"', f'id="{new}"', svg_text)
|
|
244
|
+
for old, new in mapping.items():
|
|
245
|
+
svg_text = re.sub(
|
|
246
|
+
rf'(?:xlink:)?href="#?{re.escape(old)}"', f'href="#{new}"', svg_text
|
|
247
|
+
)
|
|
248
|
+
svg_text = re.sub(
|
|
249
|
+
rf'xlink:href="#?{re.escape(old)}"',
|
|
250
|
+
f'xlink:href="#{new}"',
|
|
251
|
+
svg_text,
|
|
252
|
+
)
|
|
253
|
+
for old, new in mapping.items():
|
|
254
|
+
svg_text = re.sub(rf"url\(#\s*{re.escape(old)}\s*\)", f"url(#{new})", svg_text)
|
|
255
|
+
for old, new in mapping.items():
|
|
256
|
+
svg_text = re.sub(rf"#({re.escape(old)})\b", f"#{new}", svg_text)
|
|
257
|
+
return svg_text
|
|
258
|
+
|
|
259
|
+
unique_prefix = f"hnr_{_hash_key(p, x_val, stage, int(tutor_mode))}_{uuid.uuid4().hex[:6]}_"
|
|
260
|
+
raw_svg = _uniquify_ids(raw_svg, unique_prefix)
|
|
261
|
+
|
|
262
|
+
# Augment root <svg> (unified width handling)
|
|
263
|
+
def _augment(match):
|
|
264
|
+
tag = match.group(0)
|
|
265
|
+
if "class=" not in tag:
|
|
266
|
+
tag = tag[:-1] + ' class="horner-inline-svg"' + ">"
|
|
267
|
+
else:
|
|
268
|
+
tag = tag.replace('class="', 'class="horner-inline-svg ')
|
|
269
|
+
if alt and "aria-label=" not in tag:
|
|
270
|
+
tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
|
|
271
|
+
if width_opt:
|
|
272
|
+
w_raw = width_opt.strip()
|
|
273
|
+
if percentage_width:
|
|
274
|
+
w_css = w_raw
|
|
275
|
+
margin = "margin:0 auto;" if "margin:" not in tag else ""
|
|
276
|
+
style_frag = f"width:{w_css}; height:auto; display:block; {margin}".strip()
|
|
277
|
+
else:
|
|
278
|
+
w_css = (w_raw + "px") if w_raw.isdigit() else w_raw
|
|
279
|
+
style_frag = f"width:{w_css}; height:auto; display:block;"
|
|
280
|
+
if "style=" in tag:
|
|
281
|
+
tag = re.sub(
|
|
282
|
+
r'style="([^"]*)"',
|
|
283
|
+
lambda m: f'style="{m.group(1)}; {style_frag}"',
|
|
284
|
+
tag,
|
|
285
|
+
count=1,
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
tag = tag[:-1] + f' style="{style_frag}"' + ">"
|
|
289
|
+
return tag
|
|
290
|
+
|
|
291
|
+
raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
|
|
292
|
+
|
|
293
|
+
# Build docutils figure
|
|
294
|
+
figure = nodes.figure()
|
|
295
|
+
figure.setdefault("classes", []).extend(["adaptive-figure", "horner-figure", "no-click"])
|
|
296
|
+
|
|
297
|
+
raw_node = nodes.raw("", raw_svg, format="html")
|
|
298
|
+
raw_node.setdefault("classes", []).extend(["horner-image", "no-click", "no-scaled-link"])
|
|
299
|
+
figure += raw_node
|
|
300
|
+
|
|
301
|
+
extra_classes = self.options.get("class")
|
|
302
|
+
if extra_classes:
|
|
303
|
+
figure["classes"].extend(extra_classes)
|
|
304
|
+
figure["align"] = self.options.get("align", "center")
|
|
305
|
+
|
|
306
|
+
if self.content:
|
|
307
|
+
caption_text = "\n".join(self.content)
|
|
308
|
+
caption_node = nodes.caption()
|
|
309
|
+
caption_node += nodes.Text(caption_text)
|
|
310
|
+
figure += caption_node
|
|
311
|
+
|
|
312
|
+
if explicit_name:
|
|
313
|
+
self.add_name(figure)
|
|
314
|
+
|
|
315
|
+
return [figure]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def setup(app):
|
|
319
|
+
app.add_directive("horner", HornerDirective)
|
|
320
|
+
return {
|
|
321
|
+
"version": "0.1",
|
|
322
|
+
"parallel_read_safe": True,
|
|
323
|
+
"parallel_write_safe": True,
|
|
324
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from docutils import nodes
|
|
2
|
+
from docutils.parsers.rst import Directive, directives
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InteractiveCodeDirective(Directive):
|
|
8
|
+
has_content = True
|
|
9
|
+
required_arguments = 0 # The unique identifier
|
|
10
|
+
optional_arguments = 1
|
|
11
|
+
final_argument_whitespace = True
|
|
12
|
+
option_spec = {
|
|
13
|
+
"lang": directives.unchanged,
|
|
14
|
+
"predict": directives.flag,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def run(self):
|
|
18
|
+
# Get the unique identifier from arguments
|
|
19
|
+
# Generate a unique identifier or use the provided one
|
|
20
|
+
if self.arguments:
|
|
21
|
+
identifier = self.arguments[0]
|
|
22
|
+
else:
|
|
23
|
+
identifier = f"code-{uuid.uuid4().hex[:8]}"
|
|
24
|
+
|
|
25
|
+
container_id = f"container-{identifier}"
|
|
26
|
+
|
|
27
|
+
# Get code content from the directive content
|
|
28
|
+
code_content = "\n".join(self.content)
|
|
29
|
+
|
|
30
|
+
# Escape code for JavaScript
|
|
31
|
+
escaped_code = code_content.replace("`", "\\`").replace("$", "\\$")
|
|
32
|
+
|
|
33
|
+
is_prediction = "predict" in self.options
|
|
34
|
+
# Choose the appropriate function based on the predict flag
|
|
35
|
+
function_name = "makePredictionInteractiveCode" if is_prediction else "makeInteractiveCode"
|
|
36
|
+
|
|
37
|
+
# Create the HTML with the template
|
|
38
|
+
html = f"""
|
|
39
|
+
<div id="{container_id}"></div>
|
|
40
|
+
<script type="text/javascript">
|
|
41
|
+
document.addEventListener("DOMContentLoaded", () => {{
|
|
42
|
+
const code =
|
|
43
|
+
`{escaped_code}`;
|
|
44
|
+
|
|
45
|
+
{function_name}(
|
|
46
|
+
"{container_id}",
|
|
47
|
+
code,
|
|
48
|
+
);
|
|
49
|
+
}});
|
|
50
|
+
</script>
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
raw_node = nodes.raw("", html, format="html")
|
|
54
|
+
return [raw_node]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def setup(app):
|
|
58
|
+
app.add_directive("interactive-code", InteractiveCodeDirective)
|
|
59
|
+
|
|
60
|
+
# Ensure assets loaded even if submodule loaded directly
|
|
61
|
+
try:
|
|
62
|
+
app.add_css_file("munchboka/css/interactive_code.css")
|
|
63
|
+
app.add_js_file("munchboka/js/interactiveCode/interactiveCodeSetup.js")
|
|
64
|
+
app.add_js_file("munchboka/js/interactiveCode/codeEditor.js")
|
|
65
|
+
app.add_js_file("munchboka/js/interactiveCode/pythonRunner.js")
|
|
66
|
+
app.add_js_file("munchboka/js/interactiveCode/workerManager.js")
|
|
67
|
+
app.add_js_file("munchboka/js/interactiveCode/turtleCode.js")
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"version": "0.1",
|
|
73
|
+
"parallel_read_safe": True,
|
|
74
|
+
"parallel_write_safe": True,
|
|
75
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html as _html
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
from docutils import nodes
|
|
11
|
+
from docutils.parsers.rst import directives
|
|
12
|
+
from sphinx.util.docutils import SphinxDirective
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JeopardyDirective(SphinxDirective):
|
|
16
|
+
has_content = True
|
|
17
|
+
required_arguments = 0
|
|
18
|
+
option_spec = {
|
|
19
|
+
"teams": directives.unchanged,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def run(self):
|
|
23
|
+
self.board_id = uuid.uuid4().hex
|
|
24
|
+
container_id = f"jeopardy-{self.board_id}"
|
|
25
|
+
|
|
26
|
+
data = self._parse_board()
|
|
27
|
+
|
|
28
|
+
# Compute relative prefix to _static (if needed later)
|
|
29
|
+
source_file = self.state.document["source"]
|
|
30
|
+
source_dir = os.path.dirname(source_file)
|
|
31
|
+
app_src_dir = self.env.srcdir
|
|
32
|
+
depth = os.path.relpath(source_dir, app_src_dir).count(os.sep)
|
|
33
|
+
rel_prefix = "../" * (depth + 1)
|
|
34
|
+
|
|
35
|
+
# Prepare config both as data-config (HTML-escaped) and inline JSON script inside the container
|
|
36
|
+
cfg_str_attr = _html.escape(json.dumps(data, ensure_ascii=False), quote=True)
|
|
37
|
+
json_str = json.dumps(data, ensure_ascii=False)
|
|
38
|
+
|
|
39
|
+
# Include KaTeX like the quiz/legacy extension to ensure math renders
|
|
40
|
+
html = f"""
|
|
41
|
+
<div id="{container_id}" class=\"jeopardy-container\" lang=\"no\" data-config=\"{cfg_str_attr}\">
|
|
42
|
+
<script type=\"application/json\" class=\"jeopardy-data\">{json_str}</script>
|
|
43
|
+
</div>
|
|
44
|
+
<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex/dist/katex.min.css\">
|
|
45
|
+
<script defer src=\"https://cdn.jsdelivr.net/npm/katex/dist/katex.min.js\"></script>
|
|
46
|
+
<script defer src=\"https://cdn.jsdelivr.net/npm/katex/dist/contrib/auto-render.min.js\"></script>
|
|
47
|
+
"""
|
|
48
|
+
return [nodes.raw("", html, format="html")]
|
|
49
|
+
|
|
50
|
+
def _parse_board(self) -> Dict[str, Any]:
|
|
51
|
+
teams_opt = self.options.get("teams")
|
|
52
|
+
try:
|
|
53
|
+
teams = max(1, int(str(teams_opt).strip())) if teams_opt is not None else 2
|
|
54
|
+
except Exception:
|
|
55
|
+
teams = 2
|
|
56
|
+
|
|
57
|
+
categories: List[Dict[str, Any]] = []
|
|
58
|
+
current_cat: Dict[str, Any] | None = None
|
|
59
|
+
current_tile: Dict[str, Any] | None = None
|
|
60
|
+
current_section: str | None = None
|
|
61
|
+
values_set = set()
|
|
62
|
+
|
|
63
|
+
def flush_tile():
|
|
64
|
+
nonlocal current_tile
|
|
65
|
+
if current_cat is not None and current_tile is not None:
|
|
66
|
+
for k in ("question", "answer"):
|
|
67
|
+
if current_tile.get(k) is None:
|
|
68
|
+
current_tile[k] = ""
|
|
69
|
+
(current_cat.setdefault("tiles", [])).append(current_tile)
|
|
70
|
+
current_tile = None
|
|
71
|
+
|
|
72
|
+
for raw in self.content:
|
|
73
|
+
line = self._process_figures(raw)
|
|
74
|
+
if line is None:
|
|
75
|
+
line = raw
|
|
76
|
+
s = line.rstrip("\n")
|
|
77
|
+
|
|
78
|
+
m = re.match(r"^\s*Category\s*:\s*(.+?)\s*$", s, flags=re.IGNORECASE)
|
|
79
|
+
if m:
|
|
80
|
+
flush_tile()
|
|
81
|
+
current_cat = {"name": m.group(1).strip(), "tiles": []}
|
|
82
|
+
categories.append(current_cat)
|
|
83
|
+
current_section = None
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
m = re.match(r"^\s*(\d+)\s*:\s*$", s)
|
|
87
|
+
if m and current_cat is not None:
|
|
88
|
+
flush_tile()
|
|
89
|
+
v = int(m.group(1))
|
|
90
|
+
values_set.add(v)
|
|
91
|
+
current_tile = {"value": v, "question": "", "answer": ""}
|
|
92
|
+
current_section = None
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
m = re.match(r"^\s*Q\s*:\s*(.*)$", s, flags=re.IGNORECASE)
|
|
96
|
+
if m and current_tile is not None:
|
|
97
|
+
current_section = "Q"
|
|
98
|
+
current_tile["question"] = (current_tile.get("question") or "") + m.group(1)
|
|
99
|
+
continue
|
|
100
|
+
m = re.match(r"^\s*A\s*:\s*(.*)$", s, flags=re.IGNORECASE)
|
|
101
|
+
if m and current_tile is not None:
|
|
102
|
+
current_section = "A"
|
|
103
|
+
current_tile["answer"] = (current_tile.get("answer") or "") + m.group(1)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if current_section == "Q" and current_tile is not None:
|
|
107
|
+
current_tile["question"] = (current_tile.get("question") or "") + "\n" + s
|
|
108
|
+
continue
|
|
109
|
+
if current_section == "A" and current_tile is not None:
|
|
110
|
+
current_tile["answer"] = (current_tile.get("answer") or "") + "\n" + s
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
flush_tile()
|
|
114
|
+
|
|
115
|
+
values = sorted(values_set)
|
|
116
|
+
for cat in categories:
|
|
117
|
+
by_val = {t.get("value"): t for t in (cat.get("tiles") or [])}
|
|
118
|
+
cat["tiles"] = [by_val.get(v) for v in values if v in by_val]
|
|
119
|
+
|
|
120
|
+
for cat in categories:
|
|
121
|
+
for t in cat.get("tiles") or []:
|
|
122
|
+
for key in ("question", "answer"):
|
|
123
|
+
s = t.get(key) or ""
|
|
124
|
+
t[key] = self._process_code_blocks(s)
|
|
125
|
+
|
|
126
|
+
return {"teams": teams, "categories": categories, "values": values}
|
|
127
|
+
|
|
128
|
+
def _process_figures(self, text):
|
|
129
|
+
import shutil
|
|
130
|
+
import json as _json
|
|
131
|
+
|
|
132
|
+
if not hasattr(self, "_image_counter"):
|
|
133
|
+
self._image_counter = 0
|
|
134
|
+
|
|
135
|
+
def _parse_figure_options(alt_text):
|
|
136
|
+
opts: Dict[str, Any] = {}
|
|
137
|
+
s = (alt_text or "").strip()
|
|
138
|
+
|
|
139
|
+
def parse_pairs(text: str):
|
|
140
|
+
for m in re.finditer(r'(\w+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s}]+))', text):
|
|
141
|
+
val = m.group(2) or m.group(3) or m.group(4) or ""
|
|
142
|
+
opts[m.group(1)] = val
|
|
143
|
+
|
|
144
|
+
if s.startswith("{") and s.endswith("}"):
|
|
145
|
+
inner = s[1:-1].strip()
|
|
146
|
+
ok = False
|
|
147
|
+
try:
|
|
148
|
+
js = s
|
|
149
|
+
js = re.sub(r"(\w+)\s*:", r'"\\1":', js)
|
|
150
|
+
js = re.sub(r':\s*([^",}]+)', r': "\\1"', js)
|
|
151
|
+
opts.update(_json.loads(js))
|
|
152
|
+
ok = True
|
|
153
|
+
except Exception:
|
|
154
|
+
ok = False
|
|
155
|
+
if not ok:
|
|
156
|
+
parse_pairs(inner)
|
|
157
|
+
else:
|
|
158
|
+
parse_pairs(s)
|
|
159
|
+
if not opts and s:
|
|
160
|
+
opts["alt"] = s
|
|
161
|
+
return opts
|
|
162
|
+
|
|
163
|
+
def _build_figure_html(html_img_path, options):
|
|
164
|
+
user_opts = dict(options or {})
|
|
165
|
+
user_class = user_opts.pop("class", "").strip()
|
|
166
|
+
classes = "jeopardy-image adaptive-figure" + (f" {user_class}" if user_class else "")
|
|
167
|
+
|
|
168
|
+
alt_text = user_opts.pop("alt", "Figure")
|
|
169
|
+
title = user_opts.pop("title", None)
|
|
170
|
+
width = user_opts.pop("width", None)
|
|
171
|
+
height = user_opts.pop("height", None)
|
|
172
|
+
extra_style = user_opts.pop("style", None)
|
|
173
|
+
|
|
174
|
+
def _normalize_wh(val: Any) -> str:
|
|
175
|
+
s = str(val).strip()
|
|
176
|
+
if re.fullmatch(r"\d+(?:\.\d+)?", s):
|
|
177
|
+
return f"{s}px"
|
|
178
|
+
s = re.sub(r"\s+(?=(px|%|em|rem|vh|vw)$)", "", s)
|
|
179
|
+
return s
|
|
180
|
+
|
|
181
|
+
styles: List[str] = []
|
|
182
|
+
if width is not None:
|
|
183
|
+
styles.append(f"width: {_normalize_wh(width)};")
|
|
184
|
+
if height is not None:
|
|
185
|
+
styles.append(f"height: {_normalize_wh(height)};")
|
|
186
|
+
if extra_style:
|
|
187
|
+
styles.append(str(extra_style))
|
|
188
|
+
|
|
189
|
+
attrs = [f'src="{html_img_path}"', f'class="{classes}"', f'alt="{alt_text}"']
|
|
190
|
+
if title:
|
|
191
|
+
attrs.append(f'title="{title}"')
|
|
192
|
+
if styles:
|
|
193
|
+
style_str = " ".join(styles)
|
|
194
|
+
attrs.append(f'style="{style_str}"')
|
|
195
|
+
for k, v in user_opts.items():
|
|
196
|
+
if k not in {"src", "class", "alt", "title", "width", "height", "style"}:
|
|
197
|
+
attrs.append(f'{k}="{v}"')
|
|
198
|
+
img = f"<img {' '.join(attrs)} >"
|
|
199
|
+
return f'<div class="jeopardy-image-container">{img}</div>'
|
|
200
|
+
|
|
201
|
+
def replace(m):
|
|
202
|
+
alt_or_opts = m.group(1).strip()
|
|
203
|
+
raw_src = m.group(2)
|
|
204
|
+
self._image_counter += 1
|
|
205
|
+
options = _parse_figure_options(alt_or_opts)
|
|
206
|
+
|
|
207
|
+
source_file = self.state.document["source"]
|
|
208
|
+
source_dir = os.path.dirname(source_file)
|
|
209
|
+
app_src_dir = self.env.srcdir
|
|
210
|
+
|
|
211
|
+
abs_fig_src = os.path.normpath(os.path.join(source_dir, raw_src))
|
|
212
|
+
if not os.path.exists(abs_fig_src):
|
|
213
|
+
return f'<img src="{raw_src}" class="jeopardy-image adaptive-figure" alt="Figure (missing)">'
|
|
214
|
+
|
|
215
|
+
relative_doc_path = os.path.relpath(source_dir, app_src_dir)
|
|
216
|
+
figure_dest_dir = os.path.join(app_src_dir, "_static", "figurer", relative_doc_path)
|
|
217
|
+
os.makedirs(figure_dest_dir, exist_ok=True)
|
|
218
|
+
|
|
219
|
+
rel_path_from_source = os.path.relpath(abs_fig_src, source_dir)
|
|
220
|
+
safe_path = rel_path_from_source.replace(os.sep, "_").replace("/", "_")
|
|
221
|
+
base, ext = os.path.splitext(safe_path)
|
|
222
|
+
fig_filename = f"{self.board_id}_img{self._image_counter}_{base}{ext}"
|
|
223
|
+
fig_dest_path = os.path.join(figure_dest_dir, fig_filename)
|
|
224
|
+
shutil.copy2(abs_fig_src, fig_dest_path)
|
|
225
|
+
|
|
226
|
+
depth = os.path.relpath(source_dir, app_src_dir).count(os.sep)
|
|
227
|
+
rel_prefix = "../" * (depth + 1)
|
|
228
|
+
html_img_path = f"{rel_prefix}_static/figurer/{relative_doc_path}/{fig_filename}"
|
|
229
|
+
return _build_figure_html(html_img_path, options)
|
|
230
|
+
|
|
231
|
+
return re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", replace, text)
|
|
232
|
+
|
|
233
|
+
def _process_code_blocks(self, text: str) -> str:
|
|
234
|
+
def replace_newlines(match):
|
|
235
|
+
code = match.group(2).replace("\\n", "\n")
|
|
236
|
+
lang = match.group(1)
|
|
237
|
+
return f'<pre><code class="{lang}">{code}</code></pre>'
|
|
238
|
+
|
|
239
|
+
pattern = r'<pre><code class="([\w-]+)">(.*?)</code></pre>'
|
|
240
|
+
return re.sub(pattern, replace_newlines, text, flags=re.DOTALL)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def setup(app):
|
|
244
|
+
app.add_directive("jeopardy", JeopardyDirective)
|
|
245
|
+
# Ensure assets loaded even if submodule loaded directly
|
|
246
|
+
try:
|
|
247
|
+
app.add_css_file("munchboka/css/jeopardy.css")
|
|
248
|
+
app.add_js_file("munchboka/js/jeopardy.js")
|
|
249
|
+
app.add_css_file("munchboka/css/general_style.css")
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|