munchboka-edutools 0.1.0__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.
Files changed (150) hide show
  1. munchboka_edutools/__init__.py +182 -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 +356 -0
  6. munchboka_edutools/directives/cas_popup.py +272 -0
  7. munchboka_edutools/directives/dialogue.py +137 -0
  8. munchboka_edutools/directives/escape_room.py +296 -0
  9. munchboka_edutools/directives/factor_tree.py +549 -0
  10. munchboka_edutools/directives/ggb.py +209 -0
  11. munchboka_edutools/directives/ggb_icon.py +62 -0
  12. munchboka_edutools/directives/ggb_popup.py +165 -0
  13. munchboka_edutools/directives/horner.py +324 -0
  14. munchboka_edutools/directives/interactive_code.py +75 -0
  15. munchboka_edutools/directives/jeopardy.py +252 -0
  16. munchboka_edutools/directives/multi_plot.py +1126 -0
  17. munchboka_edutools/directives/pair_puzzle.py +191 -0
  18. munchboka_edutools/directives/parsons.py +109 -0
  19. munchboka_edutools/directives/plot.py +3012 -0
  20. munchboka_edutools/directives/poly_icon.py +91 -0
  21. munchboka_edutools/directives/polydiv.py +344 -0
  22. munchboka_edutools/directives/quiz.py +291 -0
  23. munchboka_edutools/directives/signchart.py +474 -0
  24. munchboka_edutools/directives/timed_quiz.py +436 -0
  25. munchboka_edutools/directives/turtle.py +157 -0
  26. munchboka_edutools/static/css/admonitions.css +2012 -0
  27. munchboka_edutools/static/css/cas_popup.css +242 -0
  28. munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
  29. munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
  30. munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
  31. munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
  32. munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
  33. munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
  34. munchboka_edutools/static/css/dialogue.css +92 -0
  35. munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
  36. munchboka_edutools/static/css/figures.css +274 -0
  37. munchboka_edutools/static/css/general_style.css +74 -0
  38. munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
  39. munchboka_edutools/static/css/github-dark.css +112 -0
  40. munchboka_edutools/static/css/github-light.css +120 -0
  41. munchboka_edutools/static/css/interactive_code/style.css +575 -0
  42. munchboka_edutools/static/css/interactive_code.css +582 -0
  43. munchboka_edutools/static/css/jeopardy.css +476 -0
  44. munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
  45. munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
  46. munchboka_edutools/static/css/quiz.css +312 -0
  47. munchboka_edutools/static/css/timedQuiz.css +375 -0
  48. munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
  49. munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
  50. munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
  51. munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
  52. munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
  53. munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
  54. munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
  55. munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
  56. munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
  57. munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
  58. munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
  59. munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
  60. munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
  61. munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
  62. munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
  63. munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
  64. munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
  65. munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
  66. munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
  67. munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
  68. munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
  69. munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
  70. munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
  71. munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
  72. munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
  73. munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
  74. munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
  75. munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
  76. munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
  77. munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
  78. munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
  79. munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
  80. munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
  81. munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
  82. munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
  83. munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
  84. munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
  85. munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
  86. munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
  87. munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
  88. munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
  89. munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
  90. munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
  91. munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
  92. munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
  93. munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
  94. munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
  95. munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
  96. munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
  97. munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
  98. munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
  99. munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
  100. munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
  101. munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
  102. munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
  103. munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
  104. munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
  105. munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
  106. munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
  107. munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
  108. munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
  109. munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
  110. munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
  111. munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
  112. munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
  113. munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
  114. munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
  115. munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
  116. munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
  117. munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
  118. munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
  119. munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
  120. munchboka_edutools/static/icons/stickers/edit.svg +1 -0
  121. munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
  122. munchboka_edutools/static/js/casThemeManager.js +99 -0
  123. munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
  124. munchboka_edutools/static/js/geogebra-setup.js +6 -0
  125. munchboka_edutools/static/js/highlight-init.js +6 -0
  126. munchboka_edutools/static/js/interactiveCode/codeEditor.js +632 -0
  127. munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +348 -0
  128. munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
  129. munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
  130. munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
  131. munchboka_edutools/static/js/interactive_code/codeEditor.js +662 -0
  132. munchboka_edutools/static/js/interactive_code/interactiveCodeSetup.js +252 -0
  133. munchboka_edutools/static/js/interactive_code/pythonRunner.js +145 -0
  134. munchboka_edutools/static/js/interactive_code/turtleCode.js +56 -0
  135. munchboka_edutools/static/js/interactive_code/workerManager.js +204 -0
  136. munchboka_edutools/static/js/jeopardy.js +457 -0
  137. munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
  138. munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
  139. munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
  140. munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
  141. munchboka_edutools/static/js/quiz.js +422 -0
  142. munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
  143. munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
  144. munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
  145. munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
  146. munchboka_edutools/static/js/utils.js +3 -0
  147. munchboka_edutools-0.1.0.dist-info/METADATA +107 -0
  148. munchboka_edutools-0.1.0.dist-info/RECORD +150 -0
  149. munchboka_edutools-0.1.0.dist-info/WHEEL +4 -0
  150. munchboka_edutools-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,91 @@
1
+ """
2
+ Polynomial Icon Role for Munchboka Edutools
3
+ ===========================================
4
+
5
+ This module provides a custom inline role for displaying polynomial/cubic function
6
+ icons in Sphinx documentation. It's designed for mathematical content where you need
7
+ to refer to cubic function shapes visually.
8
+
9
+ Usage in MyST Markdown:
10
+ {poly-icon}`cubicup`
11
+ {poly-icon}`cubicdown`
12
+ {poly-icon}`smile`
13
+ {poly-icon}`frown`
14
+
15
+ Available Icons:
16
+ - cubicup: Cubic function going up (standard cubic)
17
+ - cubicdown: Cubic function going down (negative cubic)
18
+ - smile: Positive quadratic (parabola opening up)
19
+ - frown: Negative quadratic (parabola opening down)
20
+
21
+ The icons are displayed inline with the text and adapt to the current theme
22
+ (light/dark mode). They use the .inline-image class for consistent styling.
23
+
24
+ Author: René Aasen (ported from matematikk_r1)
25
+ Date: November 2025
26
+ """
27
+
28
+ from docutils import nodes
29
+ from docutils.parsers.rst import roles
30
+
31
+
32
+ def poly_icon_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
33
+ """
34
+ Custom role for polynomial/cubic function icons.
35
+
36
+ Args:
37
+ name: The role name used in the document
38
+ rawtext: The entire role markup string
39
+ text: The icon name (e.g., 'cubicup', 'smile')
40
+ lineno: Line number where the role appears
41
+ inliner: The inliner instance
42
+ options: Dictionary of directive options
43
+ content: List of strings, the content of the role
44
+
45
+ Returns:
46
+ tuple: (list of nodes, list of system messages)
47
+
48
+ Example:
49
+ Input: {poly-icon}`cubicup`
50
+ Output: <img src="/_static/munchboka/icons/polyicons/cubicup.svg"
51
+ alt="Cubic cubicup icon" class="inline-image">
52
+ """
53
+ # Clean up the icon name (remove any extra whitespace)
54
+ icon_name = text.strip()
55
+
56
+ # Build the image path relative to the source directory
57
+ # Note: Using /_static/munchboka/ path structure for built HTML
58
+ img_src = f"/_static/munchboka/icons/polyicons/{icon_name}.svg"
59
+
60
+ # Create a proper image node that Sphinx can process
61
+ node = nodes.image(uri=img_src, alt=f"Cubic {icon_name} icon")
62
+
63
+ # Add the inline-image class for consistent styling
64
+ # (defined in figures.css with theme-aware styling)
65
+ node["classes"] = ["inline-image"]
66
+
67
+ return [node], []
68
+
69
+
70
+ def setup(app):
71
+ """
72
+ Setup function to register the role with Sphinx.
73
+
74
+ This function is called automatically by Sphinx when the extension is loaded.
75
+ It registers the poly-icon role for use in documentation.
76
+
77
+ Args:
78
+ app: The Sphinx application instance
79
+
80
+ Returns:
81
+ dict: Extension metadata including version and parallel processing flags
82
+ """
83
+ # Register the role with both hyphenated and unhyphenated names for compatibility
84
+ roles.register_local_role("poly-icon", poly_icon_role)
85
+ roles.register_local_role("polyicon", poly_icon_role)
86
+
87
+ return {
88
+ "version": "0.1",
89
+ "parallel_read_safe": True,
90
+ "parallel_write_safe": True,
91
+ }
@@ -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
+ }