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,436 @@
1
+ """
2
+ Timed Quiz Directive for Munchboka Edutools
3
+ ===========================================
4
+
5
+ This directive creates interactive timed quizzes with multiple-choice questions.
6
+ Students race against the clock to answer as many questions as possible correctly.
7
+
8
+ Usage in MyST Markdown:
9
+ ```{timed-quiz}
10
+ Q: What is 2 + 2?
11
+ + 4
12
+ - 3
13
+ - 5
14
+ - 6
15
+
16
+ Q: What is the capital of France?
17
+ + Paris
18
+ - London
19
+ - Berlin
20
+ - Madrid
21
+ ```
22
+
23
+ Features:
24
+ - Countdown timer with visual progress bar
25
+ - Automatic question shuffling
26
+ - Answer shuffling for each question
27
+ - Score tracking and feedback
28
+ - Support for LaTeX math rendering
29
+ - Support for code blocks with syntax highlighting
30
+ - Support for images in questions and answers
31
+ - Theme-aware styling (light/dark modes)
32
+
33
+ Dependencies:
34
+ - KaTeX: For LaTeX math rendering
35
+ - highlight.js: For code syntax highlighting
36
+ - timedMultipleChoiceQuiz.js: Main quiz logic
37
+ - multipleChoiceQuestion.js: Question rendering logic
38
+ - utils.js: Utility functions
39
+
40
+ Author: René Aasen (ported from matematikk_r1)
41
+ Date: November 2025
42
+ """
43
+
44
+ from docutils import nodes
45
+ from docutils.parsers.rst import Directive, directives
46
+ from sphinx.util.docutils import SphinxDirective
47
+ import json
48
+ import uuid
49
+ import re
50
+ import os
51
+
52
+
53
+ class TimedQuizDirective(SphinxDirective):
54
+ """
55
+ Sphinx directive for embedding interactive timed quizzes.
56
+
57
+ The directive parses quiz content in a simple text format:
58
+ - Questions start with "Q:"
59
+ - Correct answers start with "+"
60
+ - Incorrect answers start with "-"
61
+
62
+ Options:
63
+ shuffle (flag): Shuffle the order of questions (default behavior)
64
+
65
+ Example:
66
+ ```{timed-quiz}
67
+ Q: What is 2 + 2?
68
+ + 4
69
+ - 3
70
+ - 5
71
+
72
+ Q: Solve: $x^2 = 9$
73
+ + $x = \\pm 3$
74
+ - $x = 3$
75
+ - $x = 9$
76
+ ```
77
+ """
78
+
79
+ has_content = True
80
+ required_arguments = 0
81
+ optional_arguments = 0
82
+ final_argument_whitespace = True
83
+ option_spec = {
84
+ "shuffle": directives.flag,
85
+ }
86
+
87
+ def run(self):
88
+ """
89
+ Generate the HTML for the timed quiz.
90
+
91
+ Returns:
92
+ list: List of docutils nodes (raw HTML node)
93
+ """
94
+ # Generate unique ID for this quiz instance
95
+ self.quiz_id = uuid.uuid4().hex
96
+ container_id = f"quiz-container-{self.quiz_id}"
97
+
98
+ # Parse quiz content
99
+ quiz_data = self._parse_quiz_content()
100
+
101
+ # Create the HTML output
102
+ # Note: KaTeX is already loaded globally via __init__.py
103
+ html = f"""
104
+ <!-- Container for the timed quiz -->
105
+ <div id="{container_id}" class="quiz-main-container"></div>
106
+
107
+ <script type="text/javascript">
108
+ document.addEventListener("DOMContentLoaded", () => {{
109
+ // Define your questions and answers
110
+ const questionsData = {json.dumps(quiz_data, ensure_ascii=False)};
111
+
112
+ // Initialize the timed multiple-choice quiz
113
+ const quiz = new TimedMultipleChoiceQuiz('{container_id}', questionsData);
114
+ }});
115
+ </script>
116
+ """
117
+
118
+ raw_node = nodes.raw("", html, format="html")
119
+
120
+ return [raw_node]
121
+
122
+ def _parse_quiz_content(self):
123
+ """
124
+ Parse the directive content into quiz questions data.
125
+
126
+ The parser recognizes:
127
+ - Q: question text
128
+ - + correct answer
129
+ - - incorrect answer
130
+
131
+ Returns:
132
+ list: List of question dictionaries with content and answers
133
+ """
134
+ questions = []
135
+ current_question = None
136
+ current_answers = []
137
+
138
+ for line in self.content:
139
+ line = self._process_figures(line)
140
+ line = line.strip()
141
+
142
+ # Skip empty lines
143
+ if not line:
144
+ continue
145
+
146
+ # New question starts with Q:
147
+ if line.startswith("Q:"):
148
+ # Save previous question if exists
149
+ if current_question:
150
+ questions.append({"content": current_question, "answers": current_answers})
151
+
152
+ # Start new question and process newlines
153
+ question_text = line[2:].strip()
154
+ # Replace \\n with actual newlines for code blocks
155
+ question_text = self._process_code_blocks(question_text)
156
+ current_question = question_text
157
+ current_answers = []
158
+
159
+ # Correct answer starts with +
160
+ elif line.startswith("+"):
161
+ answer_text = line[1:].strip()
162
+ # Process code blocks in answers too
163
+ answer_text = self._process_code_blocks(answer_text)
164
+ current_answers.append({"content": answer_text, "isCorrect": True})
165
+
166
+ # Incorrect answer starts with -
167
+ elif line.startswith("-"):
168
+ answer_text = line[1:].strip()
169
+ # Process code blocks in answers too
170
+ answer_text = self._process_code_blocks(answer_text)
171
+ current_answers.append({"content": answer_text, "isCorrect": False})
172
+
173
+ # Add the last question
174
+ if current_question:
175
+ questions.append({"content": current_question, "answers": current_answers})
176
+
177
+ return questions
178
+
179
+ def _process_code_blocks(self, text):
180
+ """
181
+ Process code blocks to handle newlines properly.
182
+
183
+ Converts escaped newlines (\\n) to actual newlines within <pre><code> blocks.
184
+
185
+ Args:
186
+ text: Text that may contain code blocks
187
+
188
+ Returns:
189
+ str: Text with properly formatted code blocks
190
+ """
191
+
192
+ # Helper function to process code blocks by type
193
+ def replace_newlines(match):
194
+ code = match.group(2) # The actual code content
195
+ lang = match.group(1) # The language class (python or console)
196
+
197
+ # Replace escaped newlines with actual newlines
198
+ code = code.replace("\\n", "\n")
199
+ return f'<pre><code class="{lang}">{code}</code></pre>'
200
+
201
+ # Find all code blocks with any class and process them
202
+ pattern = r'<pre><code class="([\w-]+)">(.*?)</code></pre>'
203
+ text = re.sub(pattern, replace_newlines, text, flags=re.DOTALL)
204
+
205
+ return text
206
+
207
+ def _process_figures(self, text):
208
+ """
209
+ Replace Markdown images with HTML <img> tags, copy figures, and fix path.
210
+
211
+ This function:
212
+ 1. Parses Markdown image syntax: ![alt](src)
213
+ 2. Copies the image to _static/figurer/<quiz_path>/
214
+ 3. Generates unique filenames to avoid conflicts
215
+ 4. Returns HTML img tags with proper paths
216
+
217
+ Args:
218
+ text: Text that may contain Markdown image syntax
219
+
220
+ Returns:
221
+ str: Text with images converted to HTML <img> tags
222
+ """
223
+ import shutil
224
+ import re
225
+ import json
226
+
227
+ # Add a counter to track images within this quiz
228
+ if not hasattr(self, "_image_counter"):
229
+ self._image_counter = 0
230
+
231
+ def replace(match):
232
+ alt_or_options = match.group(1).strip() # Alt text or options
233
+ raw_src = match.group(2) # Image source path
234
+
235
+ # Increment image counter for unique naming
236
+ self._image_counter += 1
237
+
238
+ # Parse options from alt text
239
+ options = self._parse_figure_options(alt_or_options)
240
+
241
+ # Path of the source .md/.rst file
242
+ source_file = self.state.document["source"]
243
+ source_dir = os.path.dirname(source_file)
244
+ app_src_dir = self.env.srcdir # Root of source directory
245
+
246
+ # Absolute source path of the image file
247
+ abs_fig_src = os.path.normpath(os.path.join(source_dir, raw_src))
248
+
249
+ if not os.path.exists(abs_fig_src):
250
+ print(f"⚠️ TimedQuizDirective: Figure not found: {abs_fig_src}")
251
+ return f'<img src="{raw_src}" class="quiz-image adaptive-figure" alt="Quiz figure (missing)">'
252
+
253
+ # Determine quiz-local static path: _static/figurer/<path to .md>/<filename>
254
+ relative_doc_path = os.path.relpath(source_dir, app_src_dir)
255
+ figure_dest_dir = os.path.join(app_src_dir, "_static", "figurer", relative_doc_path)
256
+ os.makedirs(figure_dest_dir, exist_ok=True)
257
+
258
+ # Create unique filename using the full relative path to avoid conflicts
259
+ # Convert the relative path from source to a safe filename part
260
+ rel_path_from_source = os.path.relpath(abs_fig_src, source_dir)
261
+ safe_path = rel_path_from_source.replace(os.sep, "_").replace("/", "_")
262
+ base_name, ext = os.path.splitext(safe_path)
263
+
264
+ # Use quiz_id, image counter, and the safe path for uniqueness
265
+ fig_filename = f"{self.quiz_id}_img{self._image_counter}_{base_name}{ext}"
266
+ fig_dest_path = os.path.join(figure_dest_dir, fig_filename)
267
+
268
+ # Copy image
269
+ shutil.copy2(abs_fig_src, fig_dest_path)
270
+
271
+ # Now calculate relative path from output HTML to _static
272
+ depth = os.path.relpath(source_dir, app_src_dir).count(os.sep)
273
+ rel_prefix = "../" * (depth + 1)
274
+
275
+ html_img_path = f"{rel_prefix}_static/figurer/{relative_doc_path}/{fig_filename}"
276
+
277
+ # Build HTML with options
278
+ html_img = self._build_figure_html(html_img_path, options)
279
+
280
+ return html_img
281
+
282
+ # Updated regex to capture both alt text and source
283
+ return re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", replace, text)
284
+
285
+ def _parse_figure_options(self, alt_text):
286
+ """
287
+ Parse figure options from alt text.
288
+
289
+ Supports three formats:
290
+ 1. JSON-like: {width: 60%, class: adaptive-figure}
291
+ 2. HTML-style: width="60%" class="adaptive-figure"
292
+ 3. Plain text: Just alt text
293
+
294
+ Args:
295
+ alt_text: Alt text string that may contain options
296
+
297
+ Returns:
298
+ dict: Parsed options dictionary
299
+ """
300
+ options = {}
301
+
302
+ # Method 1: JSON-like syntax {width: 60%, class: adaptive-figure}
303
+ if alt_text.startswith("{") and alt_text.endswith("}"):
304
+ try:
305
+ # Clean up the syntax for JSON parsing
306
+ json_str = alt_text
307
+ # Add quotes to keys: width: -> "width":
308
+ json_str = re.sub(r"(\w+):", r'"\1":', json_str)
309
+ # Add quotes to unquoted values
310
+ json_str = re.sub(r':\s*([^",}]+)', r': "\1"', json_str)
311
+ options = json.loads(json_str)
312
+ except:
313
+ # Fallback to simple parsing
314
+ options = self._parse_simple_options(alt_text[1:-1])
315
+
316
+ # Method 2: HTML-style attributes: width="60%" class="adaptive-figure"
317
+ elif "=" in alt_text:
318
+ options = self._parse_html_style_options(alt_text)
319
+
320
+ # Method 3: Plain alt text (traditional)
321
+ else:
322
+ if alt_text:
323
+ options["alt"] = alt_text
324
+
325
+ return options
326
+
327
+ def _parse_html_style_options(self, alt_text):
328
+ """
329
+ Parse HTML-style attributes: width="60%" class="adaptive-figure"
330
+
331
+ Args:
332
+ alt_text: String with HTML-style attributes
333
+
334
+ Returns:
335
+ dict: Parsed options
336
+ """
337
+ options = {}
338
+ # Match attribute="value" or attribute='value'
339
+ pattern = r'(\w+)=(["\'])([^"\']*)\2'
340
+
341
+ for match in re.finditer(pattern, alt_text):
342
+ key = match.group(1)
343
+ value = match.group(3)
344
+ options[key] = value
345
+
346
+ return options
347
+
348
+ def _parse_simple_options(self, options_str):
349
+ """
350
+ Parse simple key: value syntax.
351
+
352
+ Args:
353
+ options_str: String with comma-separated key:value pairs
354
+
355
+ Returns:
356
+ dict: Parsed options
357
+ """
358
+ options = {}
359
+ pairs = options_str.split(",")
360
+
361
+ for pair in pairs:
362
+ if ":" in pair:
363
+ key, value = pair.split(":", 1)
364
+ key = key.strip()
365
+ value = value.strip()
366
+ # Remove quotes if present
367
+ if (value.startswith('"') and value.endswith('"')) or (
368
+ value.startswith("'") and value.endswith("'")
369
+ ):
370
+ value = value[1:-1]
371
+ options[key] = value
372
+
373
+ return options
374
+
375
+ def _build_figure_html(self, html_img_path, options):
376
+ """
377
+ Build the HTML for the figure with options.
378
+
379
+ Args:
380
+ html_img_path: Path to the image file
381
+ options: Dictionary of image options (width, class, alt, etc.)
382
+
383
+ Returns:
384
+ str: HTML markup for the image
385
+ """
386
+
387
+ # Default options
388
+ default_options = {"class": "quiz-image adaptive-figure", "alt": "Quiz figure"}
389
+
390
+ # Merge with user options (user options override defaults)
391
+ final_options = {**default_options, **options}
392
+
393
+ # Build img attributes
394
+ img_attrs = []
395
+ img_attrs.append(f'src="{html_img_path}"')
396
+
397
+ for key, value in final_options.items():
398
+ if key == "width":
399
+ img_attrs.append(f'style="width: {value};"')
400
+ elif key == "height":
401
+ img_attrs.append(f'style="height: {value};"')
402
+ elif key in ["class", "alt", "title"]:
403
+ img_attrs.append(f'{key}="{value}"')
404
+
405
+ img_tag = f'<img {" ".join(img_attrs)}>'
406
+
407
+ # Wrap in container
408
+ html_img = f"""<div class="quiz-image-container">
409
+ {img_tag}
410
+ </div>
411
+ """
412
+
413
+ return html_img
414
+
415
+
416
+ def setup(app):
417
+ """
418
+ Setup function to register the directive with Sphinx.
419
+
420
+ This function is called automatically by Sphinx when the extension is loaded.
421
+ It registers both 'timed-quiz' and 'timedquiz' directives for compatibility.
422
+
423
+ Args:
424
+ app: The Sphinx application instance
425
+
426
+ Returns:
427
+ dict: Extension metadata including version and parallel processing flags
428
+ """
429
+ app.add_directive("timed-quiz", TimedQuizDirective)
430
+ app.add_directive("timedquiz", TimedQuizDirective)
431
+
432
+ return {
433
+ "version": "0.1",
434
+ "parallel_read_safe": True,
435
+ "parallel_write_safe": True,
436
+ }
@@ -0,0 +1,157 @@
1
+ """
2
+ Turtle Graphics Directive for Munchboka Edutools
3
+ ================================================
4
+
5
+ This directive creates an interactive Python turtle graphics environment where
6
+ students can write and execute turtle code directly in the browser.
7
+
8
+ Usage in MyST Markdown:
9
+ ```{turtle}
10
+ import turtle
11
+
12
+ t = turtle.Turtle()
13
+ t.forward(100)
14
+ t.right(90)
15
+ t.forward(100)
16
+ ```
17
+
18
+ Or with custom identifier:
19
+ ```{turtle} my-turtle-1
20
+ import turtle
21
+
22
+ for i in range(4):
23
+ turtle.forward(100)
24
+ turtle.right(90)
25
+ ```
26
+
27
+ Features:
28
+ - Live code editor with syntax highlighting (CodeMirror)
29
+ - Immediate execution with "Run" button
30
+ - Visual turtle graphics canvas
31
+ - Full Python turtle module support (via Skulpt)
32
+ - Automatic canvas setup and coordinate system
33
+ - Error display in output area
34
+
35
+ Dependencies:
36
+ - CodeMirror: Code editor with Python syntax highlighting
37
+ - Skulpt: Python-in-browser implementation
38
+ - turtleCode.js: Turtle graphics environment setup
39
+ - CodeEditor class: Interactive code editing
40
+
41
+ Author: René Aasen (ported from matematikk_r1)
42
+ Date: November 2025
43
+ """
44
+
45
+ from docutils import nodes
46
+ from docutils.parsers.rst import Directive, directives
47
+ from sphinx.util.docutils import SphinxDirective
48
+ import uuid
49
+
50
+
51
+ class TurtleDirective(SphinxDirective):
52
+ """
53
+ Sphinx directive for embedding interactive turtle graphics code.
54
+
55
+ This directive creates a side-by-side layout with a code editor on the left
56
+ and a turtle graphics canvas on the right. Students can write turtle code
57
+ and see the results immediately.
58
+
59
+ Arguments:
60
+ identifier (optional): Custom identifier for the turtle environment.
61
+ If not provided, a unique ID is generated.
62
+
63
+ Options:
64
+ width (optional): Width of the container (not commonly used)
65
+ height (optional): Height of the container (not commonly used)
66
+
67
+ Example:
68
+ ```{turtle}
69
+ import turtle
70
+
71
+ t = turtle.Turtle()
72
+ t.color('blue')
73
+ t.forward(100)
74
+ t.left(120)
75
+ t.forward(100)
76
+ t.left(120)
77
+ t.forward(100)
78
+ ```
79
+
80
+ Or with identifier:
81
+
82
+ ```{turtle} drawing-square
83
+ import turtle
84
+
85
+ for i in range(4):
86
+ turtle.forward(100)
87
+ turtle.right(90)
88
+ ```
89
+ """
90
+
91
+ has_content = True
92
+ required_arguments = 0
93
+ optional_arguments = 1 # Optional identifier
94
+ final_argument_whitespace = True
95
+ option_spec = {
96
+ "width": directives.unchanged,
97
+ "height": directives.unchanged,
98
+ }
99
+
100
+ def run(self):
101
+ """
102
+ Generate the HTML for the turtle graphics environment.
103
+
104
+ Returns:
105
+ list: List of docutils nodes (raw HTML node)
106
+ """
107
+ # Generate a unique ID or use the provided one
108
+ if self.arguments:
109
+ identifier = self.arguments[0].replace(" ", "-").lower()
110
+ else:
111
+ identifier = f"turtle-{uuid.uuid4().hex[:8]}"
112
+
113
+ container_id = f"container-kode-{identifier}"
114
+
115
+ # Get the code content
116
+ code_content = "\n".join(self.content)
117
+
118
+ # Escape backticks and dollar signs for JavaScript template literals
119
+ escaped_code = code_content.replace("`", "\\`").replace("$", "\\$")
120
+
121
+ # Create the HTML output
122
+ # Note: turtleCode.js and its dependencies (CodeMirror, Skulpt, CodeEditor)
123
+ # are already loaded globally via __init__.py
124
+ html = f"""
125
+ <div id="{container_id}"></div>
126
+ <script type="text/javascript">
127
+ document.addEventListener("DOMContentLoaded", () => {{
128
+ const code = `{escaped_code}`;
129
+ makeTurtleCode("{container_id}", code);
130
+ }});
131
+ </script>
132
+ """
133
+
134
+ raw_node = nodes.raw("", html, format="html")
135
+ return [raw_node]
136
+
137
+
138
+ def setup(app):
139
+ """
140
+ Setup function to register the directive with Sphinx.
141
+
142
+ This function is called automatically by Sphinx when the extension is loaded.
143
+ It registers the 'turtle' directive for use in documentation.
144
+
145
+ Args:
146
+ app: The Sphinx application instance
147
+
148
+ Returns:
149
+ dict: Extension metadata including version and parallel processing flags
150
+ """
151
+ app.add_directive("turtle", TurtleDirective)
152
+
153
+ return {
154
+ "version": "0.1",
155
+ "parallel_read_safe": True,
156
+ "parallel_write_safe": True,
157
+ }