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,111 @@
1
+ """
2
+ Polynomial function shape icon role for inline SVG icons.
3
+
4
+ This role allows you to insert icons representing different polynomial function shapes.
5
+ The icons help visualize the general shape of polynomial functions.
6
+
7
+ Usage:
8
+ A quadratic function with positive leading coefficient has a {poly-icon}`smile` shape.
9
+
10
+ A cubic function with negative leading coefficient has a {poly-icon}`cubicdown` shape.
11
+
12
+ Available icons:
13
+ - smile: U-shaped (parabola opening upward, like a smile)
14
+ - frown: ∩-shaped (parabola opening downward, like a frown)
15
+ - cubicup: Cubic function starting low and ending high (∪∩ shape)
16
+ - cubicdown: Cubic function starting high and ending low (∩∪ shape)
17
+
18
+ The icons are rendered as inline images with appropriate alt text.
19
+ """
20
+
21
+ from docutils import nodes
22
+ from docutils.parsers.rst import roles
23
+
24
+
25
+ # Custom node for polynomial icons
26
+ class poly_icon_node(nodes.Inline, nodes.Element):
27
+ """Custom node for polynomial icons."""
28
+
29
+ pass
30
+
31
+
32
+ def poly_icon_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
33
+ """
34
+ Custom role for polynomial function shape icons.
35
+
36
+ Usage: {poly-icon}`smile`
37
+
38
+ This generates a custom node that will be rendered with relative paths.
39
+ """
40
+ # Clean up the icon name (remove any extra whitespace)
41
+ icon_name = text.strip().lower()
42
+
43
+ # Validate icon name
44
+ valid_icons = ["smile", "frown", "cubicup", "cubicdown"]
45
+ if icon_name not in valid_icons:
46
+ msg = inliner.reporter.error(
47
+ f'Invalid poly-icon name "{icon_name}". Must be one of: {", ".join(valid_icons)}',
48
+ line=lineno,
49
+ )
50
+ prb = inliner.problematic(rawtext, rawtext, msg)
51
+ return [prb], [msg]
52
+
53
+ # Create our custom node
54
+ node = poly_icon_node()
55
+ node["icon_name"] = icon_name
56
+ node["classes"] = ["inline-image"]
57
+
58
+ return [node], []
59
+
60
+
61
+ def visit_poly_icon_html(self, node):
62
+ """HTML visitor for poly_icon_node - generates relative path."""
63
+ icon_name = node["icon_name"]
64
+
65
+ # Get the relative path from current document to _static directory
66
+ # self.builder.current_docname is like "examples/poly_icon"
67
+ # We want path from there to ../_static/munchboka/icons/polyicons/
68
+ from os.path import dirname
69
+
70
+ # Current document directory depth
71
+ doc_dir = dirname(self.builder.current_docname) if "/" in self.builder.current_docname else ""
72
+
73
+ # Calculate relative path
74
+ if doc_dir:
75
+ # For documents in subdirectories like "examples/poly_icon"
76
+ depth = doc_dir.count("/") + 1
77
+ rel_prefix = "../" * depth
78
+ else:
79
+ # For top-level documents
80
+ rel_prefix = ""
81
+
82
+ img_path = f"{rel_prefix}_static/munchboka/icons/polyicons/{icon_name}.svg"
83
+
84
+ # Generate the HTML with relative path
85
+ html = f'<img src="{img_path}" alt="{icon_name} polynomial icon" class="inline-image" />'
86
+ self.body.append(html)
87
+ raise nodes.SkipNode
88
+
89
+
90
+ def depart_poly_icon_html(self, node):
91
+ """Depart function (not needed as we raise SkipNode)."""
92
+ pass
93
+
94
+
95
+ def setup(app):
96
+ """Setup function to register the role with Sphinx."""
97
+ # Register the custom node
98
+ app.add_node(
99
+ poly_icon_node,
100
+ html=(visit_poly_icon_html, depart_poly_icon_html),
101
+ )
102
+
103
+ # Register the role with both hyphenated and unhyphenated names
104
+ roles.register_local_role("poly-icon", poly_icon_role)
105
+ roles.register_local_role("polyicon", poly_icon_role)
106
+
107
+ return {
108
+ "version": "0.1.0",
109
+ "parallel_read_safe": True,
110
+ "parallel_write_safe": True,
111
+ }
@@ -0,0 +1,344 @@
1
+ """
2
+ Polynomial long division directive for Sphinx/Jupyter Book.
3
+
4
+ Generates SVG visualizations of polynomial long 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 polylongdiv(fname: str, p: str, q: str, stage: int = None, svg: bool = True, vars=None):
29
+ """
30
+ Generate polynomial long division figure using LaTeX.
31
+
32
+ Args:
33
+ fname: Base filename (without extension)
34
+ p: Dividend polynomial as string (e.g., "x^3 + 2x^2 - 3x - 6")
35
+ q: Divisor polynomial as string (e.g., "x - 2")
36
+ stage: Optional stage number for step-by-step display
37
+ svg: If True, convert to SVG; otherwise keep as PDF
38
+ vars: Variable(s) used in polynomials (default: "x")
39
+ """
40
+ if not vars:
41
+ vars = "x"
42
+
43
+ # Generate unique temp filenames
44
+ temp_id = uuid.uuid4().hex[:8]
45
+ tex_file = f"tmp_{temp_id}.tex"
46
+ pdf_file = f"tmp_{temp_id}.pdf"
47
+
48
+ # Format LaTeX command
49
+ if stage is None:
50
+ div_cmd = r"\polylongdiv[style=C, div=:, vars=None]{{p}}{{q}}"
51
+ div_cmd = div_cmd.replace("{p}", p).replace("{q}", q).replace("None", str(vars))
52
+ else:
53
+ div_cmd = r"\polylongdiv[style=C, div=:, stage={stage}]{{p}}{{q}}"
54
+ div_cmd = div_cmd.replace("{p}", p).replace("{q}", q).replace("{stage}", str(stage))
55
+
56
+ # Create LaTeX file
57
+ s = f"""\\documentclass[border=0.2cm]{{standalone}}
58
+ \\usepackage{{polynom}}
59
+ \\begin{{document}}
60
+ {div_cmd}
61
+ \\end{{document}}
62
+ """
63
+
64
+ # Remove .svg extension properly if present
65
+ if fname.endswith(".svg"):
66
+ fname = fname[:-4]
67
+
68
+ # Write and process files
69
+ with open(tex_file, "w") as f:
70
+ f.write(s)
71
+
72
+ os.system(f"pdflatex {tex_file}")
73
+
74
+ if svg:
75
+ os.system(f"pdf2svg {pdf_file} {fname}.svg")
76
+ else:
77
+ os.system(f"mv {pdf_file} {fname}.pdf")
78
+
79
+ # Cleanup temp files
80
+ os.system(f"rm tmp_{temp_id}.*")
81
+
82
+
83
+ class PolyDivDirective(SphinxDirective):
84
+ """
85
+ Generate (and cache) a polynomial long division figure as SVG and embed it.
86
+
87
+ Usage (MyST):
88
+
89
+ ```
90
+ ::::{polydiv}
91
+ :p: x^3 + 2x^2 - 3x - 6
92
+ :q: x - 2
93
+ :stage: 2 # optional
94
+ :vars: x # optional (default x)
95
+ :align: center # optional (left|center|right)
96
+ :class: small # optional extra CSS classes on figure
97
+
98
+ Optional caption here.
99
+ ::::
100
+ ```
101
+
102
+ Or classic reStructuredText:
103
+
104
+ ```
105
+ .. polydiv::
106
+ :p: x^3 + 2x^2 - 3x - 6
107
+ :q: x - 2
108
+ :stage: 2
109
+ :vars: x
110
+
111
+ Optional caption here.
112
+ ```
113
+ """
114
+
115
+ has_content = True
116
+ required_arguments = 0
117
+ optional_arguments = 0
118
+ final_argument_whitespace = False
119
+ option_spec = {
120
+ "p": directives.unchanged_required,
121
+ "q": directives.unchanged_required,
122
+ "stage": directives.nonnegative_int,
123
+ "vars": directives.unchanged,
124
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
125
+ "class": directives.class_option,
126
+ "name": directives.unchanged, # optional explicit base filename / ref name
127
+ "cache": directives.flag, # include to enable (default on)
128
+ "nocache": directives.flag, # include to force regeneration
129
+ "alt": directives.unchanged,
130
+ "width": directives.length_or_percentage_or_unitless,
131
+ # Always inline now; legacy 'inline' option kept (ignored) for backward compatibility
132
+ "inline": directives.flag,
133
+ }
134
+
135
+ def run(self):
136
+ """Main directive entry: always inline-embed generated SVG and allow width control & centering."""
137
+ env = self.state.document.settings.env
138
+ app = env.app
139
+
140
+ # Options
141
+ p = self.options.get("p")
142
+ q = self.options.get("q")
143
+ if p is None or q is None:
144
+ return [
145
+ self.state_machine.reporter.error(
146
+ "Directive 'polydiv' requires both :p: and :q: options.",
147
+ line=self.lineno,
148
+ )
149
+ ]
150
+ stage = self.options.get("stage")
151
+ vars_opt = self.options.get("vars", "x")
152
+ explicit_name = self.options.get("name")
153
+ content_hash = _hash_key(p, q, stage, vars_opt)
154
+ base_name = explicit_name or f"polydiv_{content_hash}"
155
+
156
+ # Paths
157
+ src_dir = app.srcdir
158
+ rel_dir = os.path.join("_static", "polydiv")
159
+ abs_dir = os.path.join(src_dir, rel_dir)
160
+ os.makedirs(abs_dir, exist_ok=True)
161
+ svg_filename = f"{base_name}.svg"
162
+ abs_svg_path = os.path.join(abs_dir, svg_filename)
163
+
164
+ regenerate = "nocache" in self.options or not os.path.exists(abs_svg_path)
165
+ if regenerate:
166
+ cwd = os.getcwd()
167
+ try:
168
+ os.chdir(abs_dir)
169
+ polylongdiv(
170
+ fname=base_name,
171
+ p=p,
172
+ q=q,
173
+ stage=stage,
174
+ vars=vars_opt,
175
+ )
176
+ except Exception as e:
177
+ return [
178
+ self.state_machine.reporter.error(
179
+ f"Error generating polynomial division: {e}",
180
+ line=self.lineno,
181
+ )
182
+ ]
183
+ finally:
184
+ os.chdir(cwd)
185
+
186
+ # Post-process: strip width/height for responsiveness if viewBox present
187
+ try:
188
+ if os.path.exists(abs_svg_path):
189
+ with open(abs_svg_path, "r", encoding="utf-8") as f_svg:
190
+ svg_text_tmp = f_svg.read()
191
+ if "viewBox" in svg_text_tmp:
192
+ cleaned = re.sub(r'\swidth="[^"]+"', "", svg_text_tmp)
193
+ cleaned = re.sub(r'\sheight="[^"]+"', "", cleaned)
194
+ if cleaned != svg_text_tmp:
195
+ with open(abs_svg_path, "w", encoding="utf-8") as f_out:
196
+ f_out.write(cleaned)
197
+ except Exception:
198
+ pass
199
+
200
+ if not os.path.exists(abs_svg_path):
201
+ return [
202
+ self.state_machine.reporter.error(
203
+ (
204
+ f"polydiv: failed to generate SVG '{svg_filename}'. "
205
+ "Check that 'pdflatex' and 'pdf2svg' are installed."
206
+ ),
207
+ line=self.lineno,
208
+ )
209
+ ]
210
+
211
+ env.note_dependency(abs_svg_path)
212
+ try:
213
+ out_static = os.path.join(app.outdir, "_static", "polydiv")
214
+ os.makedirs(out_static, exist_ok=True)
215
+ shutil.copy2(abs_svg_path, os.path.join(out_static, svg_filename))
216
+ except Exception:
217
+ pass
218
+
219
+ # Alt text
220
+ if stage is not None:
221
+ default_alt = f"Polynomial division of ({p}) : ({q}) – stage {stage}"
222
+ else:
223
+ default_alt = f"Polynomial division of ({p}) : ({q})"
224
+ alt = self.options.get("alt", default_alt)
225
+
226
+ width_opt = self.options.get("width")
227
+ percentage_width = isinstance(width_opt, str) and width_opt.strip().endswith("%")
228
+
229
+ # Read final SVG
230
+ try:
231
+ with open(abs_svg_path, "r", encoding="utf-8") as f_svg:
232
+ raw_svg = f_svg.read()
233
+ except Exception as e:
234
+ return [
235
+ self.state_machine.reporter.error(
236
+ f"polydiv inline: could not read SVG: {e}",
237
+ line=self.lineno,
238
+ )
239
+ ]
240
+
241
+ # Uniquify IDs to prevent collisions
242
+ def _uniquify_ids(svg_text: str, prefix: str) -> str:
243
+ ids = set(re.findall(r'\bid="([^"]+)"', svg_text))
244
+ if not ids:
245
+ return svg_text
246
+ mapping = {old: f"{prefix}{old}" for old in ids}
247
+ for old, new in mapping.items():
248
+ svg_text = re.sub(rf'\bid="{re.escape(old)}"', f'id="{new}"', svg_text)
249
+ for old, new in mapping.items():
250
+ svg_text = re.sub(
251
+ rf'(?:xlink:)?href="#?{re.escape(old)}"', f'href="#{new}"', svg_text
252
+ )
253
+ svg_text = re.sub(
254
+ rf'xlink:href="#?{re.escape(old)}"',
255
+ f'xlink:href="#{new}"',
256
+ svg_text,
257
+ )
258
+ for old, new in mapping.items():
259
+ svg_text = re.sub(rf"url\(#\s*{re.escape(old)}\s*\)", f"url(#{new})", svg_text)
260
+ for old, new in mapping.items():
261
+ svg_text = re.sub(rf"#({re.escape(old)})\b", f"#{new}", svg_text)
262
+ return svg_text
263
+
264
+ unique_prefix = f"pd_{_hash_key(p, q, stage, vars_opt)}_{uuid.uuid4().hex[:6]}_"
265
+ raw_svg = _uniquify_ids(raw_svg, unique_prefix)
266
+
267
+ # Augment root <svg> (single pass handles both percent and fixed widths)
268
+ def _augment(match):
269
+ tag = match.group(0)
270
+ if "class=" not in tag:
271
+ tag = tag[:-1] + ' class="polydiv-inline-svg"' + ">"
272
+ else:
273
+ tag = tag.replace('class="', 'class="polydiv-inline-svg ')
274
+ if alt and "aria-label=" not in tag:
275
+ tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
276
+ if width_opt:
277
+ w_raw = width_opt.strip()
278
+ if percentage_width:
279
+ # percentage width: keep percent, center block
280
+ w_css = w_raw
281
+ margin = "margin:0 auto;" if "margin:" not in tag else ""
282
+ style_frag = f"width:{w_css}; height:auto; display:block; {margin}".strip()
283
+ else:
284
+ # fixed / unit width: add px if bare number
285
+ w_css = (w_raw + "px") if w_raw.isdigit() else w_raw
286
+ style_frag = f"width:{w_css}; height:auto; display:block;"
287
+ if "style=" in tag:
288
+ tag = re.sub(
289
+ r'style="([^"]*)"',
290
+ lambda m: f'style="{m.group(1)}; {style_frag}"',
291
+ tag,
292
+ count=1,
293
+ )
294
+ else:
295
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
296
+ return tag
297
+
298
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
299
+
300
+ figure = nodes.figure()
301
+ figure.setdefault("classes", []).extend(
302
+ [
303
+ "adaptive-figure",
304
+ "polydiv-figure",
305
+ "no-click",
306
+ ]
307
+ )
308
+
309
+ raw_node = nodes.raw("", raw_svg, format="html")
310
+ raw_node.setdefault("classes", []).extend(
311
+ [
312
+ "polydiv-image",
313
+ "no-click",
314
+ "no-scaled-link",
315
+ ]
316
+ )
317
+ figure += raw_node
318
+
319
+ # Extra classes & alignment
320
+ extra_classes = self.options.get("class")
321
+ if extra_classes:
322
+ figure["classes"].extend(extra_classes)
323
+ figure["align"] = self.options.get("align", "center")
324
+
325
+ # Caption
326
+ if self.content:
327
+ caption_text = "\n".join(self.content)
328
+ caption_node = nodes.caption()
329
+ caption_node += nodes.Text(caption_text)
330
+ figure += caption_node
331
+
332
+ if explicit_name:
333
+ self.add_name(figure)
334
+
335
+ return [figure]
336
+
337
+
338
+ def setup(app):
339
+ app.add_directive("polydiv", PolyDivDirective)
340
+ return {
341
+ "version": "0.1",
342
+ "parallel_read_safe": True,
343
+ "parallel_write_safe": True,
344
+ }
@@ -0,0 +1,245 @@
1
+ """
2
+ Popup directive and role for creating modal dialogs.
3
+
4
+ This extension provides two ways to create popups:
5
+
6
+ 1. **Directive**: Creates a button that opens a dialog with content
7
+ 2. **Role**: Creates inline text that shows a tooltip/bubble on hover
8
+
9
+ Directive Usage:
10
+ ```{popup} Button Text
11
+ :title: Dialog Title
12
+ :width: 600
13
+ :height: 400
14
+
15
+ Content goes here. Supports **markdown** and $\\LaTeX$ math.
16
+ ```
17
+
18
+ Role Usage:
19
+ This is {popup}`hover text <tooltip content>` inline.
20
+
21
+ The popup directive creates a modal dialog using jQuery UI, with automatic
22
+ KaTeX rendering for mathematical expressions. The role creates a simple
23
+ hover bubble for quick information.
24
+ """
25
+
26
+ from docutils import nodes
27
+ from sphinx.util.docutils import SphinxDirective, SphinxRole
28
+ from docutils.parsers.rst import directives
29
+ import uuid
30
+ import re
31
+
32
+
33
+ class PopupDirective(SphinxDirective):
34
+ """
35
+ Directive for creating a button that opens a modal dialog.
36
+
37
+ Options:
38
+ - title: Dialog title (default: "Mer informasjon")
39
+ - width: Dialog width in pixels (default: 500)
40
+ - height: Dialog height in pixels or "auto" (default: "auto")
41
+
42
+ Arguments:
43
+ - Button text (optional, default: "Vis mer")
44
+
45
+ Content:
46
+ - The content to display in the dialog (supports MyST markdown)
47
+ """
48
+
49
+ has_content = True
50
+ option_spec = {
51
+ "title": directives.unchanged,
52
+ "width": directives.positive_int,
53
+ "height": directives.unchanged,
54
+ }
55
+ required_arguments = 0
56
+ optional_arguments = 1 # Button text
57
+
58
+ def run(self):
59
+ # Get button text from arguments or use default
60
+ button_text = self.arguments[0] if self.arguments else "Vis mer"
61
+
62
+ # Get title from options or use default
63
+ dialog_title = self.options.get("title", "Mer informasjon")
64
+
65
+ # Get width and height if specified
66
+ width = self.options.get("width", 500)
67
+ height = self.options.get("height", "auto")
68
+
69
+ # Generate unique IDs
70
+ popup_id = f"popup-{uuid.uuid4().hex[:8]}"
71
+ button_id = f"button-{popup_id}"
72
+ content_id = f"content-{popup_id}"
73
+
74
+ # Process the directive content as markdown
75
+ content_html = "\n".join(self.content)
76
+
77
+ # Create the raw HTML with jQuery UI and KaTeX
78
+ html = f"""
79
+ <!-- Button to open the popup -->
80
+ <button id="{button_id}" class="popup-button">{button_text}</button>
81
+
82
+ <!-- Dialog container -->
83
+ <div id="{content_id}" class="popup-content" style="display:none;">
84
+ {content_html}
85
+ </div>
86
+
87
+ <!-- Script for KaTeX and dialog setup -->
88
+ <script>
89
+ (function() {{
90
+ // Function to load scripts sequentially
91
+ function loadScript(src, id) {{
92
+ return new Promise((resolve, reject) => {{
93
+ // Check if script is already loaded
94
+ if (document.getElementById(id)) {{
95
+ resolve();
96
+ return;
97
+ }}
98
+
99
+ const script = document.createElement('script');
100
+ script.id = id;
101
+ script.src = src;
102
+ script.async = false; // Important: maintain loading order
103
+ script.onload = () => resolve();
104
+ script.onerror = () => reject(new Error(`Failed to load script: ${{src}}`));
105
+ document.head.appendChild(script);
106
+ }});
107
+ }}
108
+
109
+ // Function to load stylesheets
110
+ function loadStylesheet(href, id) {{
111
+ return new Promise((resolve) => {{
112
+ if (document.getElementById(id)) {{
113
+ resolve();
114
+ return;
115
+ }}
116
+
117
+ const link = document.createElement('link');
118
+ link.id = id;
119
+ link.rel = 'stylesheet';
120
+ link.href = href;
121
+ link.onload = resolve;
122
+ document.head.appendChild(link);
123
+ }});
124
+ }}
125
+
126
+ // Initialize dialog once everything is loaded
127
+ function initializeDialog() {{
128
+ $("#{content_id}").dialog({{
129
+ autoOpen: false,
130
+ title: "{dialog_title}",
131
+ width: {width},
132
+ dialogClass: "popup-dialog",
133
+ modal: false,
134
+ resizable: true,
135
+ draggable: true,
136
+ open: function() {{
137
+ // Safety check to ensure KaTeX is available
138
+ if (window.katex && window.renderMathInElement) {{
139
+ try {{
140
+ renderMathInElement(document.getElementById('{content_id}'), {{
141
+ delimiters: [
142
+ {{left: "$$", right: "$$", display: true}},
143
+ {{left: "$", right: "$", display: false}},
144
+ {{left: "\\\\(", right: "\\\\)", display: false}},
145
+ {{left: "\\\\[", right: "\\\\]", display: true}}
146
+ ],
147
+ throwOnError: false
148
+ }});
149
+ }} catch (e) {{
150
+ console.error("KaTeX rendering error:", e);
151
+ }}
152
+ }}
153
+ }}
154
+ }});
155
+
156
+ // Button click handler
157
+ $("#{button_id}").on("click", function() {{
158
+ $("#{content_id}").dialog("open");
159
+ }});
160
+ }}
161
+
162
+ // Main initialization function
163
+ async function initialize() {{
164
+ try {{
165
+ // First, load jQuery if not already available
166
+ if (!window.jQuery) {{
167
+ await loadScript('https://code.jquery.com/jquery-3.6.0.min.js', 'jquery-script');
168
+ }}
169
+
170
+ // Then jQuery UI - safer check
171
+ if (!window.jQuery || typeof window.jQuery.ui === 'undefined') {{
172
+ await loadScript('https://code.jquery.com/ui/1.13.2/jquery-ui.min.js', 'jquery-ui-script');
173
+ await loadStylesheet('https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css', 'jquery-ui-css');
174
+ }}
175
+
176
+ // Then KaTeX and auto-render in sequence
177
+ await loadStylesheet('https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css', 'katex-css');
178
+ await loadScript('https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js', 'katex-script');
179
+ await loadScript('https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js', 'katex-auto-render');
180
+
181
+ // Initialize dialog after all scripts are loaded
182
+ initializeDialog();
183
+ }} catch (error) {{
184
+ console.error('Error initializing popup:', error);
185
+ }}
186
+ }}
187
+
188
+ // Start initialization when document is ready
189
+ if (document.readyState === 'loading') {{
190
+ document.addEventListener('DOMContentLoaded', initialize);
191
+ }} else {{
192
+ initialize();
193
+ }}
194
+ }})();
195
+ </script>
196
+ """
197
+ return [nodes.raw("", html, format="html")]
198
+
199
+
200
+ class PopupRole(SphinxRole):
201
+ """
202
+ Role for creating inline popups/tooltips.
203
+
204
+ Usage:
205
+ {popup}`label <content>`
206
+
207
+ The label is what appears in the text, and the content appears
208
+ in a tooltip/bubble when hovering over the label.
209
+ """
210
+
211
+ def run(self):
212
+ text = self.text
213
+ content_html = ""
214
+
215
+ # Parse the input: label <content>
216
+ options_match = re.search(r"<([^>]+)>\s*(?:\(([^)]+)\))?", text)
217
+ if options_match:
218
+ label = text[: options_match.start()].strip()
219
+ content_html = options_match.group(1)
220
+ else:
221
+ label = text
222
+
223
+ popup_id = f"popup-role-{uuid.uuid4().hex[:8]}"
224
+
225
+ html = f"""
226
+ <span class="popup-wrapper" id="{popup_id}">
227
+ <span class="popup-trigger">{label}</span>
228
+ <span class="popup-bubble" style="display:none;">{content_html}</span>
229
+ </span>
230
+ """
231
+
232
+ return [nodes.raw("", html, format="html")], []
233
+
234
+
235
+ def setup(app):
236
+ """Setup function to register the directive and role with Sphinx."""
237
+ # Register the directive and role
238
+ app.add_directive("popup", PopupDirective)
239
+ app.add_role("popup", PopupRole())
240
+
241
+ return {
242
+ "version": "0.1.0",
243
+ "parallel_read_safe": True,
244
+ "parallel_write_safe": True,
245
+ }