munchboka-edutools 0.2.3__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 +428 -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/escape_room2.py +318 -0
- munchboka_edutools/directives/factor_tree.py +552 -0
- munchboka_edutools/directives/flashcards.py +233 -0
- munchboka_edutools/directives/ggb.py +209 -0
- munchboka_edutools/directives/ggb_icon.py +105 -0
- munchboka_edutools/directives/ggb_popup.py +308 -0
- munchboka_edutools/directives/horner.py +326 -0
- munchboka_edutools/directives/interactive_code.py +75 -0
- munchboka_edutools/directives/jeopardy.py +252 -0
- munchboka_edutools/directives/jeopardy2.py +636 -0
- munchboka_edutools/directives/multi_plot.py +2524 -0
- munchboka_edutools/directives/multi_plot2.py +252 -0
- munchboka_edutools/directives/pair_puzzle.py +191 -0
- munchboka_edutools/directives/parsons.py +109 -0
- munchboka_edutools/directives/plot.py +3758 -0
- munchboka_edutools/directives/poly_icon.py +111 -0
- munchboka_edutools/directives/polydiv.py +346 -0
- munchboka_edutools/directives/popup.py +245 -0
- munchboka_edutools/directives/quiz.py +291 -0
- munchboka_edutools/directives/quiz2.py +453 -0
- munchboka_edutools/directives/signchart.py +519 -0
- munchboka_edutools/directives/signchart2.py +1545 -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 +321 -0
- munchboka_edutools/static/css/flashcards.css +219 -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 +147 -0
- munchboka_edutools/static/css/github-light.css +155 -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 +553 -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 +377 -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/flashcards.js +199 -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 +648 -0
- munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +441 -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 +560 -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 +566 -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.2.3.dist-info/METADATA +109 -0
- munchboka_edutools-0.2.3.dist-info/RECORD +157 -0
- munchboka_edutools-0.2.3.dist-info/WHEEL +4 -0
- munchboka_edutools-0.2.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sign Chart Directive for Munchboka Edutools
|
|
3
|
+
==========================================
|
|
4
|
+
|
|
5
|
+
This directive generates sign charts (fortegnsskjema) for polynomial functions
|
|
6
|
+
using the external `signchart` package. Sign charts are visual representations
|
|
7
|
+
showing where a polynomial function is positive, negative, or zero.
|
|
8
|
+
|
|
9
|
+
Usage in MyST Markdown:
|
|
10
|
+
```{signchart}
|
|
11
|
+
---
|
|
12
|
+
function: x**2 - 4, f(x)
|
|
13
|
+
factors: true
|
|
14
|
+
width: 100%
|
|
15
|
+
---
|
|
16
|
+
Optional caption text
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Dependencies:
|
|
20
|
+
- signchart: External Python package for generating sign charts
|
|
21
|
+
- matplotlib: Used internally by signchart
|
|
22
|
+
|
|
23
|
+
Features:
|
|
24
|
+
- Automatic polynomial factorization display
|
|
25
|
+
- Configurable width and alignment
|
|
26
|
+
- SVG output with theme-aware styling
|
|
27
|
+
- Caching for faster builds
|
|
28
|
+
- Accessible with aria-label support
|
|
29
|
+
|
|
30
|
+
Author: René Aasen (ported from matematikk_r1)
|
|
31
|
+
Date: November 2025
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import shutil
|
|
40
|
+
import uuid
|
|
41
|
+
from typing import Any, Dict, List, Tuple
|
|
42
|
+
|
|
43
|
+
from docutils import nodes
|
|
44
|
+
from docutils.parsers.rst import directives
|
|
45
|
+
from sphinx.util.docutils import SphinxDirective
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ------------------------------------
|
|
49
|
+
# Utilities
|
|
50
|
+
# ------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _hash_key(*parts) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Generate a short hash key from multiple parts.
|
|
56
|
+
|
|
57
|
+
Used for creating unique filenames based on function content.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
*parts: Variable number of parts to hash
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
str: 12-character hex hash
|
|
64
|
+
"""
|
|
65
|
+
h = hashlib.sha1()
|
|
66
|
+
for p in parts:
|
|
67
|
+
if p is None:
|
|
68
|
+
p = "__NONE__"
|
|
69
|
+
h.update(str(p).encode("utf-8"))
|
|
70
|
+
h.update(b"||")
|
|
71
|
+
return h.hexdigest()[:12]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_literal(val: str):
|
|
75
|
+
"""
|
|
76
|
+
Safely evaluate a string as a Python literal.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
val: String to evaluate
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Evaluated value or None if evaluation fails
|
|
83
|
+
"""
|
|
84
|
+
import ast
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
return ast.literal_eval(val)
|
|
88
|
+
except Exception:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_bool(val, default: bool | None = None) -> bool | None:
|
|
93
|
+
"""
|
|
94
|
+
Parse a value as a boolean.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
val: Value to parse (bool, str, or None)
|
|
98
|
+
default: Default value if parsing fails
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
bool | None: Parsed boolean value or default
|
|
102
|
+
"""
|
|
103
|
+
if val is None:
|
|
104
|
+
return default
|
|
105
|
+
if isinstance(val, bool):
|
|
106
|
+
return val
|
|
107
|
+
s = str(val).strip().lower()
|
|
108
|
+
if s == "":
|
|
109
|
+
return True
|
|
110
|
+
if s in {"true", "yes", "on", "1"}:
|
|
111
|
+
return True
|
|
112
|
+
if s in {"false", "no", "off", "0"}:
|
|
113
|
+
return False
|
|
114
|
+
return default
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _strip_root_svg_size(svg_text: str) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Remove width/height attributes from the root <svg> tag.
|
|
120
|
+
|
|
121
|
+
This allows CSS to control the SVG size.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
svg_text: Raw SVG content
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
str: SVG with width/height removed from root tag
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def repl(m):
|
|
131
|
+
tag = m.group(0)
|
|
132
|
+
tag = re.sub(r'\swidth="[^"]+"', "", tag)
|
|
133
|
+
tag = re.sub(r'\sheight="[^"]+"', "", tag)
|
|
134
|
+
return tag
|
|
135
|
+
|
|
136
|
+
return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _rewrite_ids(txt: str, prefix: str) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Rewrite all id attributes in SVG to avoid conflicts.
|
|
142
|
+
|
|
143
|
+
When multiple SVGs are on the same page, id conflicts can cause
|
|
144
|
+
rendering issues. This function prefixes all ids with a unique prefix.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
txt: SVG content
|
|
148
|
+
prefix: Prefix to add to all ids
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
str: SVG with rewritten ids
|
|
152
|
+
"""
|
|
153
|
+
ids = re.findall(r'\bid="([^"]+)"', txt)
|
|
154
|
+
if not ids:
|
|
155
|
+
return txt
|
|
156
|
+
skip_prefixes = (
|
|
157
|
+
"DejaVu",
|
|
158
|
+
"CM",
|
|
159
|
+
"STIX",
|
|
160
|
+
"Nimbus",
|
|
161
|
+
"Bitstream",
|
|
162
|
+
"Arial",
|
|
163
|
+
"Times",
|
|
164
|
+
"Helvetica",
|
|
165
|
+
)
|
|
166
|
+
mapping = {}
|
|
167
|
+
for i in ids:
|
|
168
|
+
if i.startswith(skip_prefixes):
|
|
169
|
+
continue
|
|
170
|
+
mapping[i] = f"{prefix}{i}"
|
|
171
|
+
if not mapping:
|
|
172
|
+
return txt
|
|
173
|
+
|
|
174
|
+
def repl_id(m: re.Match) -> str:
|
|
175
|
+
old = m.group(1)
|
|
176
|
+
new = mapping.get(old, old)
|
|
177
|
+
return f'id="{new}"'
|
|
178
|
+
|
|
179
|
+
txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
|
|
180
|
+
|
|
181
|
+
def repl_url(m: re.Match) -> str:
|
|
182
|
+
old = m.group(1).strip()
|
|
183
|
+
new = mapping.get(old, old)
|
|
184
|
+
return f"url(#{new})"
|
|
185
|
+
|
|
186
|
+
txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
|
|
187
|
+
|
|
188
|
+
def repl_href(m: re.Match) -> str:
|
|
189
|
+
attr = m.group(1)
|
|
190
|
+
quote = m.group(2)
|
|
191
|
+
old = m.group(3).strip()
|
|
192
|
+
new = mapping.get(old, old)
|
|
193
|
+
return f"{attr}={quote}#{new}{quote}"
|
|
194
|
+
|
|
195
|
+
txt = re.sub(r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2', repl_href, txt)
|
|
196
|
+
return txt
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class SignChartDirective(SphinxDirective):
|
|
200
|
+
"""
|
|
201
|
+
Sphinx directive for generating sign charts of polynomial functions.
|
|
202
|
+
|
|
203
|
+
This directive uses the `signchart` package to generate visual representations
|
|
204
|
+
showing where a polynomial function is positive, negative, or zero.
|
|
205
|
+
|
|
206
|
+
Options:
|
|
207
|
+
function (required): The polynomial expression and optional label
|
|
208
|
+
Format: "expression, label" or ("expression", "label")
|
|
209
|
+
Example: "x**2 - 4, f(x)"
|
|
210
|
+
factors (optional): Whether to show factored form (default: true)
|
|
211
|
+
xmin (optional): Minimum x-value for the domain (custom domain)
|
|
212
|
+
xmax (optional): Maximum x-value for the domain (custom domain)
|
|
213
|
+
width (optional): Width of the chart (e.g., "100%", "500px", "500")
|
|
214
|
+
align (optional): Alignment ("left", "center", "right")
|
|
215
|
+
class (optional): Additional CSS classes
|
|
216
|
+
name (optional): Reference name for the figure
|
|
217
|
+
nocache (optional): Force regeneration of the chart
|
|
218
|
+
debug (optional): Keep original SVG dimensions and ids
|
|
219
|
+
alt (optional): Alt text for accessibility (default: "Fortegnsskjema")
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
```{signchart}
|
|
223
|
+
---
|
|
224
|
+
function: x**2 - 4, f(x)
|
|
225
|
+
factors: true
|
|
226
|
+
width: 80%
|
|
227
|
+
---
|
|
228
|
+
Sign chart for f(x) = x² - 4
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
With custom domain:
|
|
232
|
+
```{signchart}
|
|
233
|
+
---
|
|
234
|
+
function: -3/2 * k**2 + 9/2, A'(k)
|
|
235
|
+
xmin: 0
|
|
236
|
+
xmax: 3
|
|
237
|
+
width: 100%
|
|
238
|
+
---
|
|
239
|
+
Sign chart restricted to domain [0, 3]
|
|
240
|
+
```
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
has_content = True
|
|
244
|
+
required_arguments = 0
|
|
245
|
+
option_spec = {
|
|
246
|
+
# presentation / misc
|
|
247
|
+
"width": directives.length_or_percentage_or_unitless,
|
|
248
|
+
"align": lambda a: directives.choice(a, ["left", "center", "right"]),
|
|
249
|
+
"class": directives.class_option,
|
|
250
|
+
"name": directives.unchanged,
|
|
251
|
+
"nocache": directives.flag,
|
|
252
|
+
"debug": directives.flag,
|
|
253
|
+
"alt": directives.unchanged,
|
|
254
|
+
# specific options
|
|
255
|
+
"function": directives.unchanged_required, # e.g. "x**2 - 4, f(x)"
|
|
256
|
+
"factors": directives.unchanged, # default True
|
|
257
|
+
"xmin": directives.unchanged, # custom domain minimum
|
|
258
|
+
"xmax": directives.unchanged, # custom domain maximum
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
def _parse_kv_block(self) -> Tuple[Dict[str, Any], int]:
|
|
262
|
+
"""
|
|
263
|
+
Parse YAML-style key-value block from directive content.
|
|
264
|
+
|
|
265
|
+
Supports two formats:
|
|
266
|
+
1. YAML front-matter style with --- delimiters
|
|
267
|
+
2. Simple key: value pairs at the start
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
tuple: (dict of parsed options, index where caption starts)
|
|
271
|
+
"""
|
|
272
|
+
lines = list(self.content)
|
|
273
|
+
scalars: Dict[str, Any] = {}
|
|
274
|
+
idx = 0
|
|
275
|
+
if lines and lines[0].strip() == "---":
|
|
276
|
+
idx = 1
|
|
277
|
+
while idx < len(lines) and lines[idx].strip() != "---":
|
|
278
|
+
line = lines[idx].rstrip()
|
|
279
|
+
if not line.strip():
|
|
280
|
+
idx += 1
|
|
281
|
+
continue
|
|
282
|
+
m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
|
|
283
|
+
if m:
|
|
284
|
+
scalars[m.group(1)] = m.group(2)
|
|
285
|
+
idx += 1
|
|
286
|
+
if idx < len(lines) and lines[idx].strip() == "---":
|
|
287
|
+
idx += 1
|
|
288
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
289
|
+
idx += 1
|
|
290
|
+
return scalars, idx
|
|
291
|
+
|
|
292
|
+
caption_start = 0
|
|
293
|
+
for i, line in enumerate(lines):
|
|
294
|
+
if not line.strip():
|
|
295
|
+
caption_start = i + 1
|
|
296
|
+
continue
|
|
297
|
+
m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
|
|
298
|
+
if m:
|
|
299
|
+
scalars[m.group(1)] = m.group(2)
|
|
300
|
+
caption_start = i + 1
|
|
301
|
+
else:
|
|
302
|
+
break
|
|
303
|
+
return scalars, caption_start
|
|
304
|
+
|
|
305
|
+
def run(self): # noqa: C901
|
|
306
|
+
"""
|
|
307
|
+
Generate the sign chart.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
list: List of docutils nodes (figure containing SVG)
|
|
311
|
+
"""
|
|
312
|
+
env = self.state.document.settings.env
|
|
313
|
+
app = env.app
|
|
314
|
+
try:
|
|
315
|
+
import signchart # type: ignore
|
|
316
|
+
except Exception as e:
|
|
317
|
+
err = nodes.error()
|
|
318
|
+
err += nodes.paragraph(text=f"Could not import signchart: {e}")
|
|
319
|
+
return [err]
|
|
320
|
+
|
|
321
|
+
scalars, caption_idx = self._parse_kv_block()
|
|
322
|
+
merged: Dict[str, Any] = {**scalars, **self.options}
|
|
323
|
+
|
|
324
|
+
func_raw = merged.get("function")
|
|
325
|
+
if not func_raw:
|
|
326
|
+
return [
|
|
327
|
+
self.state_machine.reporter.error(
|
|
328
|
+
"Directive 'signchart' requires 'function:' option", line=self.lineno
|
|
329
|
+
)
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
# Parse function as either (expr, label) literal or "expr, label"
|
|
333
|
+
f_expr = None
|
|
334
|
+
f_name = None
|
|
335
|
+
lit = _safe_literal(str(func_raw))
|
|
336
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 1:
|
|
337
|
+
f_expr = str(lit[0]).strip()
|
|
338
|
+
if len(lit) > 1:
|
|
339
|
+
f_name = str(lit[1]).strip() or None
|
|
340
|
+
else:
|
|
341
|
+
s = str(func_raw)
|
|
342
|
+
if "," in s:
|
|
343
|
+
expr, label = s.split(",", 1)
|
|
344
|
+
f_expr = expr.strip()
|
|
345
|
+
label = label.strip()
|
|
346
|
+
f_name = label or None
|
|
347
|
+
else:
|
|
348
|
+
f_expr = s.strip()
|
|
349
|
+
f_name = None
|
|
350
|
+
|
|
351
|
+
include_factors = _parse_bool(merged.get("factors"), default=True)
|
|
352
|
+
explicit_name = merged.get("name")
|
|
353
|
+
debug_mode = "debug" in merged
|
|
354
|
+
|
|
355
|
+
# Parse custom domain if provided
|
|
356
|
+
xmin_val = merged.get("xmin")
|
|
357
|
+
xmax_val = merged.get("xmax")
|
|
358
|
+
custom_domain = None
|
|
359
|
+
if xmin_val is not None and xmax_val is not None:
|
|
360
|
+
try:
|
|
361
|
+
xmin_float = float(xmin_val)
|
|
362
|
+
xmax_float = float(xmax_val)
|
|
363
|
+
custom_domain = (xmin_float, xmax_float)
|
|
364
|
+
except (ValueError, TypeError):
|
|
365
|
+
return [
|
|
366
|
+
self.state_machine.reporter.warning(
|
|
367
|
+
f"signchart: Could not parse xmin='{xmin_val}' and xmax='{xmax_val}' as floats. Ignoring domain.",
|
|
368
|
+
line=self.lineno,
|
|
369
|
+
)
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
# Hash includes function, name, factors, and domain
|
|
373
|
+
content_hash = _hash_key(
|
|
374
|
+
f_expr,
|
|
375
|
+
f_name or "",
|
|
376
|
+
int(bool(include_factors)),
|
|
377
|
+
str(custom_domain) if custom_domain else "",
|
|
378
|
+
)
|
|
379
|
+
base_name = explicit_name or f"signchart_{content_hash}"
|
|
380
|
+
|
|
381
|
+
rel_dir = os.path.join("_static", "signchart")
|
|
382
|
+
abs_dir = os.path.join(app.srcdir, rel_dir)
|
|
383
|
+
os.makedirs(abs_dir, exist_ok=True)
|
|
384
|
+
svg_name = f"{base_name}.svg"
|
|
385
|
+
abs_svg = os.path.join(abs_dir, svg_name)
|
|
386
|
+
|
|
387
|
+
regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
|
|
388
|
+
if regenerate:
|
|
389
|
+
try:
|
|
390
|
+
# Render using signchart and save as SVG
|
|
391
|
+
plot_kwargs = {
|
|
392
|
+
"f": f_expr,
|
|
393
|
+
"fn_name": f_name or None,
|
|
394
|
+
"include_factors": bool(include_factors),
|
|
395
|
+
}
|
|
396
|
+
# Add domain if custom domain is specified
|
|
397
|
+
if custom_domain is not None:
|
|
398
|
+
plot_kwargs["domain"] = custom_domain
|
|
399
|
+
|
|
400
|
+
signchart.plot(**plot_kwargs)
|
|
401
|
+
signchart.savefig(dirname=abs_dir, fname=svg_name)
|
|
402
|
+
except Exception as e:
|
|
403
|
+
return [
|
|
404
|
+
self.state_machine.reporter.error(
|
|
405
|
+
f"Error generating sign chart: {e}",
|
|
406
|
+
line=self.lineno,
|
|
407
|
+
)
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
if not os.path.exists(abs_svg):
|
|
411
|
+
return [
|
|
412
|
+
self.state_machine.reporter.error("signchart: SVG file missing.", line=self.lineno)
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
env.note_dependency(abs_svg)
|
|
416
|
+
# copy into build _static
|
|
417
|
+
try:
|
|
418
|
+
out_static = os.path.join(app.outdir, "_static", "signchart")
|
|
419
|
+
os.makedirs(out_static, exist_ok=True)
|
|
420
|
+
shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
|
|
421
|
+
except Exception:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
raw_svg = open(abs_svg, "r", encoding="utf-8").read()
|
|
426
|
+
except Exception as e:
|
|
427
|
+
return [
|
|
428
|
+
self.state_machine.reporter.error(
|
|
429
|
+
f"signchart inline: could not read SVG: {e}", line=self.lineno
|
|
430
|
+
)
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
if not debug_mode and "viewBox" in raw_svg:
|
|
434
|
+
raw_svg = _strip_root_svg_size(raw_svg)
|
|
435
|
+
|
|
436
|
+
if not debug_mode:
|
|
437
|
+
raw_svg = _rewrite_ids(raw_svg, f"sgc_{content_hash}_{uuid.uuid4().hex[:6]}_")
|
|
438
|
+
|
|
439
|
+
alt_default = "Fortegnsskjema"
|
|
440
|
+
alt = merged.get("alt", alt_default)
|
|
441
|
+
|
|
442
|
+
width_opt = merged.get("width")
|
|
443
|
+
percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
|
|
444
|
+
|
|
445
|
+
def _augment(m):
|
|
446
|
+
"""Add classes, aria-label, and width styling to root SVG tag."""
|
|
447
|
+
tag = m.group(0)
|
|
448
|
+
if "class=" not in tag:
|
|
449
|
+
tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
|
|
450
|
+
else:
|
|
451
|
+
tag = tag.replace('class="', 'class="graph-inline-svg ')
|
|
452
|
+
if alt and "aria-label=" not in tag:
|
|
453
|
+
tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
|
|
454
|
+
if width_opt:
|
|
455
|
+
if percent:
|
|
456
|
+
wval = width_opt.strip()
|
|
457
|
+
else:
|
|
458
|
+
wval = width_opt.strip()
|
|
459
|
+
if wval.isdigit():
|
|
460
|
+
wval += "px"
|
|
461
|
+
style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
|
|
462
|
+
if "style=" in tag:
|
|
463
|
+
tag = re.sub(
|
|
464
|
+
r'style="([^"]*)"',
|
|
465
|
+
lambda mm: f'style="{mm.group(1)}; {style_frag}"',
|
|
466
|
+
tag,
|
|
467
|
+
count=1,
|
|
468
|
+
)
|
|
469
|
+
else:
|
|
470
|
+
tag = tag[:-1] + f' style="{style_frag}"' + ">"
|
|
471
|
+
return tag
|
|
472
|
+
|
|
473
|
+
raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
|
|
474
|
+
# Suppress automatic <title> insertion to avoid browser hover tooltips.
|
|
475
|
+
# Accessibility is maintained via role="img" and aria-label set above.
|
|
476
|
+
|
|
477
|
+
figure = nodes.figure()
|
|
478
|
+
figure.setdefault("classes", []).extend(["adaptive-figure", "signchart-figure", "no-click"])
|
|
479
|
+
raw_node = nodes.raw("", raw_svg, format="html")
|
|
480
|
+
raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
|
|
481
|
+
figure += raw_node
|
|
482
|
+
|
|
483
|
+
extra_classes = merged.get("class")
|
|
484
|
+
if extra_classes:
|
|
485
|
+
figure["classes"].extend(extra_classes)
|
|
486
|
+
figure["align"] = merged.get("align", "center")
|
|
487
|
+
|
|
488
|
+
caption_lines = list(self.content)[caption_idx:]
|
|
489
|
+
while caption_lines and not caption_lines[0].strip():
|
|
490
|
+
caption_lines.pop(0)
|
|
491
|
+
if caption_lines:
|
|
492
|
+
caption = nodes.caption()
|
|
493
|
+
caption_text = "\n".join(caption_lines)
|
|
494
|
+
# Parse as inline text to support math while avoiding extra paragraph nodes
|
|
495
|
+
parsed_nodes, messages = self.state.inline_text(caption_text, self.lineno)
|
|
496
|
+
caption.extend(parsed_nodes)
|
|
497
|
+
figure += caption
|
|
498
|
+
|
|
499
|
+
if explicit_name := merged.get("name"):
|
|
500
|
+
self.add_name(figure)
|
|
501
|
+
return [figure]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def setup(app):
|
|
505
|
+
"""
|
|
506
|
+
Setup function to register the directive with Sphinx.
|
|
507
|
+
|
|
508
|
+
This function is called automatically by Sphinx when the extension is loaded.
|
|
509
|
+
It registers both 'signchart' and 'sign-chart' directives for compatibility.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
app: The Sphinx application instance
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
dict: Extension metadata including version and parallel processing flags
|
|
516
|
+
"""
|
|
517
|
+
app.add_directive("signchart", SignChartDirective)
|
|
518
|
+
app.add_directive("sign-chart", SignChartDirective)
|
|
519
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|