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,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}