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