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.

Files changed (157) 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 +428 -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/escape_room2.py +318 -0
  11. munchboka_edutools/directives/factor_tree.py +552 -0
  12. munchboka_edutools/directives/flashcards.py +233 -0
  13. munchboka_edutools/directives/ggb.py +209 -0
  14. munchboka_edutools/directives/ggb_icon.py +105 -0
  15. munchboka_edutools/directives/ggb_popup.py +308 -0
  16. munchboka_edutools/directives/horner.py +326 -0
  17. munchboka_edutools/directives/interactive_code.py +75 -0
  18. munchboka_edutools/directives/jeopardy.py +252 -0
  19. munchboka_edutools/directives/jeopardy2.py +636 -0
  20. munchboka_edutools/directives/multi_plot.py +2524 -0
  21. munchboka_edutools/directives/multi_plot2.py +252 -0
  22. munchboka_edutools/directives/pair_puzzle.py +191 -0
  23. munchboka_edutools/directives/parsons.py +109 -0
  24. munchboka_edutools/directives/plot.py +3758 -0
  25. munchboka_edutools/directives/poly_icon.py +111 -0
  26. munchboka_edutools/directives/polydiv.py +346 -0
  27. munchboka_edutools/directives/popup.py +245 -0
  28. munchboka_edutools/directives/quiz.py +291 -0
  29. munchboka_edutools/directives/quiz2.py +453 -0
  30. munchboka_edutools/directives/signchart.py +519 -0
  31. munchboka_edutools/directives/signchart2.py +1545 -0
  32. munchboka_edutools/directives/timed_quiz.py +436 -0
  33. munchboka_edutools/directives/turtle.py +157 -0
  34. munchboka_edutools/static/css/admonitions.css +2012 -0
  35. munchboka_edutools/static/css/cas_popup.css +242 -0
  36. munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
  37. munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
  38. munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
  39. munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
  40. munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
  41. munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
  42. munchboka_edutools/static/css/dialogue.css +92 -0
  43. munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
  44. munchboka_edutools/static/css/figures.css +321 -0
  45. munchboka_edutools/static/css/flashcards.css +219 -0
  46. munchboka_edutools/static/css/general_style.css +74 -0
  47. munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
  48. munchboka_edutools/static/css/github-dark.css +147 -0
  49. munchboka_edutools/static/css/github-light.css +155 -0
  50. munchboka_edutools/static/css/interactive_code/style.css +575 -0
  51. munchboka_edutools/static/css/interactive_code.css +582 -0
  52. munchboka_edutools/static/css/jeopardy.css +553 -0
  53. munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
  54. munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
  55. munchboka_edutools/static/css/popup.css +115 -0
  56. munchboka_edutools/static/css/quiz.css +377 -0
  57. munchboka_edutools/static/css/timedQuiz.css +375 -0
  58. munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
  59. munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
  60. munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
  61. munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
  62. munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
  63. munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
  64. munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
  65. munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
  66. munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
  67. munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
  68. munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
  69. munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
  70. munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
  71. munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
  72. munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
  73. munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
  74. munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
  75. munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
  76. munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
  77. munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
  78. munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
  79. munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
  80. munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
  81. munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
  82. munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
  83. munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
  84. munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
  85. munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
  86. munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
  87. munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
  88. munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
  89. munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
  90. munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
  91. munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
  92. munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
  93. munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
  94. munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
  95. munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
  96. munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
  97. munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
  98. munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
  99. munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
  100. munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
  101. munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
  102. munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
  103. munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
  104. munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
  105. munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
  106. munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
  107. munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
  108. munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
  109. munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
  110. munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
  111. munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
  112. munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
  113. munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
  114. munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
  115. munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
  116. munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
  117. munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
  118. munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
  119. munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
  120. munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
  121. munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
  122. munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
  123. munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
  124. munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
  125. munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
  126. munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
  127. munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
  128. munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
  129. munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
  130. munchboka_edutools/static/icons/stickers/edit.svg +1 -0
  131. munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
  132. munchboka_edutools/static/js/casThemeManager.js +99 -0
  133. munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
  134. munchboka_edutools/static/js/flashcards.js +199 -0
  135. munchboka_edutools/static/js/geogebra-setup.js +6 -0
  136. munchboka_edutools/static/js/highlight-init.js +6 -0
  137. munchboka_edutools/static/js/interactiveCode/codeEditor.js +648 -0
  138. munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +441 -0
  139. munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
  140. munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
  141. munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
  142. munchboka_edutools/static/js/jeopardy.js +560 -0
  143. munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
  144. munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
  145. munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
  146. munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
  147. munchboka_edutools/static/js/popup.js +85 -0
  148. munchboka_edutools/static/js/quiz.js +566 -0
  149. munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
  150. munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
  151. munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
  152. munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
  153. munchboka_edutools/static/js/utils.js +3 -0
  154. munchboka_edutools-0.2.3.dist-info/METADATA +109 -0
  155. munchboka_edutools-0.2.3.dist-info/RECORD +157 -0
  156. munchboka_edutools-0.2.3.dist-info/WHEEL +4 -0
  157. munchboka_edutools-0.2.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1545 @@
1
+ """
2
+ Sign Chart 2 Directive for Munchboka Edutools
3
+ ==============================================
4
+
5
+ An improved sign chart directive with embedded plotting logic that supports:
6
+ - Polynomial functions
7
+ - Rational functions
8
+ - Transcendental functions (sin, cos, exp, log, etc.)
9
+ - Custom domains for numerical zero finding
10
+ - Flexible styling options
11
+
12
+ Usage in MyST Markdown:
13
+ ```{signchart-2}
14
+ ---
15
+ function: x**2 - 4, f(x)
16
+ factors: true
17
+ domain: (-5, 5)
18
+ width: 100%
19
+ ---
20
+ Optional caption text
21
+ ```
22
+
23
+ Features:
24
+ - Automatic factorization and zero finding
25
+ - Support for singularities (poles/discontinuities)
26
+ - Numerical zero finding for transcendental functions
27
+ - Configurable colors, labels, and figure sizes
28
+ - SVG output with theme-aware styling
29
+ - Caching for faster builds
30
+
31
+ Author: René Aasen
32
+ Date: January 2026
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ import os
39
+ import re
40
+ import shutil
41
+ import uuid
42
+ import platform
43
+ import warnings
44
+ from typing import Any, Dict, List, Tuple
45
+
46
+ import numpy as np
47
+ import matplotlib.pyplot as plt
48
+ import sympy as sp
49
+
50
+ from docutils import nodes
51
+ from docutils.parsers.rst import directives
52
+ from sphinx.util.docutils import SphinxDirective
53
+
54
+
55
+ # ------------------------------------
56
+ # LaTeX Configuration
57
+ # ------------------------------------
58
+
59
+ # Check if LaTeX is available
60
+ if platform.system() == "Windows":
61
+ latex_available = shutil.which("latex.exe") is not None
62
+ else:
63
+ latex_available = shutil.which("latex") is not None
64
+
65
+ if latex_available:
66
+ try:
67
+ plt.rc("text", usetex=True)
68
+ except (FileNotFoundError, RuntimeError):
69
+ plt.rc("text", usetex=False)
70
+ else:
71
+ plt.rc("text", usetex=False)
72
+
73
+
74
+ # ------------------------------------
75
+ # Core Plotting Functions
76
+ # ------------------------------------
77
+
78
+
79
+ def get_zeros_and_singularities(f, x, domain=None):
80
+ """Find zeros and singularities (poles/discontinuities) of arbitrary functions.
81
+
82
+ Args:
83
+ f: sympy expression
84
+ x: variable symbol
85
+ domain: optional tuple (x_min, x_max) to search for numerical zeros
86
+
87
+ Returns:
88
+ dict with 'zeros' and 'singularities' lists containing symbolic/numeric values
89
+ """
90
+ zeros = []
91
+ singularities = []
92
+
93
+ # Detect singularities FIRST (for rational/transcendental functions)
94
+ try:
95
+ # Check denominator for rational functions
96
+ numer, denom = f.as_numer_denom()
97
+ if denom != 1:
98
+ denom_zeros = sp.solve(denom, x, domain=sp.S.Reals)
99
+ for z in denom_zeros:
100
+ if z.is_real or (hasattr(z, "is_real") and z.is_real is not False):
101
+ singularities.append(z)
102
+ except:
103
+ pass
104
+
105
+ # Try symbolic solving for zeros
106
+ try:
107
+ symbolic_zeros = sp.solve(f, x, domain=sp.S.Reals)
108
+ if symbolic_zeros:
109
+ for z in symbolic_zeros:
110
+ if z.is_real or (hasattr(z, "is_real") and z.is_real is not False):
111
+ # Make sure this isn't a singularity
112
+ is_sing = False
113
+ for sing in singularities:
114
+ try:
115
+ if abs(float((z - sing).evalf())) < 1e-10:
116
+ is_sing = True
117
+ break
118
+ except:
119
+ pass
120
+ if not is_sing:
121
+ zeros.append(z)
122
+ except (NotImplementedError, ValueError):
123
+ pass
124
+
125
+ # For transcendental/complex functions, find numerical zeros if domain provided
126
+ if domain:
127
+ try:
128
+ # Sample points to find sign changes
129
+ x_min, x_max = domain
130
+ test_points = np.linspace(float(x_min), float(x_max), 1000)
131
+ f_lamb = sp.lambdify(x, f, modules=["numpy"])
132
+
133
+ # Suppress warnings for evaluation at singularities
134
+ with warnings.catch_warnings():
135
+ warnings.simplefilter("ignore")
136
+ y_vals = []
137
+ valid_x = []
138
+ for xp in test_points:
139
+ try:
140
+ yp = float(f_lamb(xp))
141
+ # Exclude points near singularities
142
+ near_sing = False
143
+ for sing in singularities:
144
+ try:
145
+ if abs(xp - float(sing.evalf())) < 1e-4:
146
+ near_sing = True
147
+ break
148
+ except:
149
+ pass
150
+ if not near_sing and np.isfinite(yp) and abs(yp) < 1e10:
151
+ y_vals.append(yp)
152
+ valid_x.append(xp)
153
+ except:
154
+ pass
155
+
156
+ y_vals = np.array(y_vals)
157
+ valid_x = np.array(valid_x)
158
+
159
+ # Find sign changes (potential zeros) and near-zero values
160
+ numerical_zeros = []
161
+ if len(y_vals) > 1:
162
+ # Check for sign changes
163
+ signs = np.sign(y_vals)
164
+ sign_changes = np.where(np.diff(signs) != 0)[0]
165
+
166
+ for idx in sign_changes:
167
+ # Skip if both values are zero (avoid duplicates)
168
+ if abs(y_vals[idx]) < 1e-10 and abs(y_vals[idx + 1]) < 1e-10:
169
+ continue
170
+ # Refine zero location
171
+ try:
172
+ from scipy.optimize import brentq
173
+
174
+ x_zero = brentq(f_lamb, valid_x[idx], valid_x[idx + 1], xtol=1e-10)
175
+ numerical_zeros.append(sp.Float(x_zero))
176
+ except:
177
+ # Use midpoint if brentq fails
178
+ x_zero = (valid_x[idx] + valid_x[idx + 1]) / 2
179
+ numerical_zeros.append(sp.Float(x_zero))
180
+
181
+ # Also check for near-zero values at sample points
182
+ near_zero_idx = np.where(np.abs(y_vals) < 1e-10)[0]
183
+ for idx in near_zero_idx:
184
+ x_val = sp.Float(valid_x[idx])
185
+ # Verify it's actually a zero by checking nearby points
186
+ # (avoid singularities that happen to evaluate near zero)
187
+ try:
188
+ y_val = float(f_lamb(float(x_val)))
189
+ eps = 1e-6
190
+ y_left = float(f_lamb(float(x_val) - eps))
191
+ y_right = float(f_lamb(float(x_val) + eps))
192
+ # Only add if function is continuous and actually crosses zero
193
+ if np.isfinite(y_left) and np.isfinite(y_right) and abs(y_val) < 1e-8:
194
+ # Avoid duplicates
195
+ if not any(abs(float(z - x_val)) < 1e-8 for z in numerical_zeros):
196
+ numerical_zeros.append(x_val)
197
+ except:
198
+ pass
199
+
200
+ # Merge symbolic and numerical zeros, removing duplicates and singularities
201
+ all_zeros = list(zeros) # Start with symbolic zeros
202
+ for nz in numerical_zeros:
203
+ # Check if this is actually a singularity
204
+ is_singularity = False
205
+ for sing in singularities:
206
+ try:
207
+ if abs(float(sing.evalf() - nz.evalf())) < 1e-6:
208
+ is_singularity = True
209
+ break
210
+ except:
211
+ pass
212
+
213
+ if is_singularity:
214
+ continue # Skip singularities
215
+
216
+ # Check if this zero is already in the list (symbolic or numeric)
217
+ is_duplicate = False
218
+ for z in all_zeros:
219
+ try:
220
+ if abs(float(z.evalf() - nz.evalf())) < 1e-8:
221
+ is_duplicate = True
222
+ break
223
+ except:
224
+ pass
225
+ if not is_duplicate:
226
+ all_zeros.append(nz)
227
+
228
+ zeros = all_zeros
229
+ except:
230
+ pass
231
+
232
+ return {"zeros": zeros, "singularities": singularities}
233
+
234
+
235
+ def get_factors(polynomial, x):
236
+ """Get factors for polynomial functions (legacy support)."""
237
+ polynomial = sp.expand(polynomial)
238
+ factor_list = sp.factor_list(polynomial)
239
+ leading_coeff = factor_list[0]
240
+
241
+ linear_factors = (
242
+ [{"expression": leading_coeff, "exponent": 1, "root": -np.inf}]
243
+ if leading_coeff != 1
244
+ else []
245
+ )
246
+
247
+ for linear_factor, exponent in factor_list[1]:
248
+ exponent = int(exponent)
249
+ roots = sp.solve(linear_factor, x)
250
+
251
+ # Handle factors that may have multiple real roots (e.g., quadratics like x^2 - 2)
252
+ if not roots:
253
+ linear_factors.append(
254
+ {
255
+ "expression": linear_factor,
256
+ "exponent": exponent,
257
+ "root": -np.inf,
258
+ }
259
+ )
260
+ else:
261
+ # For each root of the factor, create a separate entry with the correct exponent
262
+ for root_value in roots:
263
+ # Only include real roots
264
+ if root_value.is_real:
265
+ linear_factors.append(
266
+ {
267
+ "expression": sp.simplify(x - root_value),
268
+ "exponent": exponent, # Use the actual exponent from factorization
269
+ "root": root_value,
270
+ }
271
+ )
272
+
273
+ return linear_factors
274
+
275
+
276
+ def get_transcendental_factors(f, x, zeros, singularities):
277
+ """Extract factors from transcendental or composite functions.
278
+
279
+ Args:
280
+ f: sympy expression
281
+ x: variable symbol
282
+ zeros: list of zeros found numerically/symbolically
283
+ singularities: list of singularities (poles)
284
+
285
+ Returns:
286
+ list of factor dictionaries (one per unique expression+exponent, with all roots)
287
+ """
288
+ factors = []
289
+
290
+ # First check if it's a rational function with transcendental parts
291
+ try:
292
+ numer, denom = f.as_numer_denom()
293
+ if denom != 1 and not (denom.is_polynomial() and numer.is_polynomial()):
294
+ # Transcendental rational function - process numerator and denominator separately
295
+ # Process numerator
296
+ numer_factors = _extract_factors_from_expression(numer, x, zeros, [])
297
+ factors.extend(numer_factors)
298
+
299
+ # Process denominator
300
+ denom_factors = _extract_factors_from_expression(denom, x, [], singularities)
301
+ factors.extend(denom_factors)
302
+
303
+ return factors
304
+ except:
305
+ pass
306
+
307
+ # Not a rational function, process as a single expression
308
+ return _extract_factors_from_expression(f, x, zeros, singularities)
309
+
310
+
311
+ def _extract_factors_from_expression(expr, x, zeros, singularities):
312
+ """Helper function to extract factors from a single expression (not a rational function).
313
+
314
+ Args:
315
+ expr: sympy expression (numerator or denominator)
316
+ x: variable symbol
317
+ zeros: zeros to associate with this expression
318
+ singularities: singularities to associate with this expression
319
+
320
+ Returns:
321
+ list of factor dictionaries
322
+ """
323
+ factors = []
324
+
325
+ try:
326
+ # Try to factor the expression
327
+ factored = sp.factor(expr)
328
+
329
+ # Check if it's a power expression (e.g., cos(x)**3)
330
+ if factored.is_Pow:
331
+ base = factored.base
332
+ exponent = int(factored.exp) if factored.exp.is_integer else 1
333
+
334
+ # Find zeros of the base
335
+ base_zeros = []
336
+ try:
337
+ base_zeros_sym = sp.solve(base, x, domain=sp.S.Reals)
338
+ for z in base_zeros_sym:
339
+ if z.is_real:
340
+ # Match with known zeros
341
+ for known_zero in zeros:
342
+ try:
343
+ if abs(float(z.evalf()) - float(known_zero.evalf())) < 1e-8:
344
+ base_zeros.append(known_zero)
345
+ break
346
+ except:
347
+ pass
348
+ except:
349
+ # If symbolic solve fails, use all known zeros
350
+ base_zeros = zeros
351
+
352
+ # Create single factor with all zeros
353
+ if base_zeros:
354
+ factors.append(
355
+ {
356
+ "expression": base,
357
+ "exponent": exponent,
358
+ "roots": base_zeros, # Changed: store all roots together
359
+ }
360
+ )
361
+
362
+ # If it's a product, try to extract individual factors
363
+ elif factored.is_Mul:
364
+ # Group factors by (expression, exponent) to avoid duplicates
365
+ factor_dict = {}
366
+
367
+ for arg in factored.args:
368
+ # Check if this arg is a power
369
+ if arg.is_Pow:
370
+ base = arg.base
371
+ exponent = int(arg.exp) if arg.exp.is_integer else 1
372
+ expr_to_use = base
373
+ else:
374
+ expr_to_use = arg
375
+ exponent = 1
376
+
377
+ # Create a key for grouping
378
+ key = (str(expr_to_use), exponent)
379
+
380
+ if key not in factor_dict:
381
+ factor_dict[key] = {
382
+ "expression": expr_to_use,
383
+ "exponent": exponent,
384
+ "zeros": [],
385
+ "singularities": [],
386
+ }
387
+
388
+ # Find zeros of this factor
389
+ try:
390
+ arg_zeros = sp.solve(expr_to_use, x, domain=sp.S.Reals)
391
+ for z in arg_zeros:
392
+ if z.is_real:
393
+ # Check if this zero is in our list
394
+ for known_zero in zeros:
395
+ try:
396
+ if abs(float(z.evalf()) - float(known_zero.evalf())) < 1e-8:
397
+ if known_zero not in factor_dict[key]["zeros"]:
398
+ factor_dict[key]["zeros"].append(known_zero)
399
+ break
400
+ except:
401
+ pass
402
+ except:
403
+ pass
404
+
405
+ # Check for singularities in denominator
406
+ try:
407
+ numer, denom = expr_to_use.as_numer_denom()
408
+ if denom != 1:
409
+ denom_zeros = sp.solve(denom, x, domain=sp.S.Reals)
410
+ for z in denom_zeros:
411
+ if z.is_real:
412
+ for known_sing in singularities:
413
+ try:
414
+ if abs(float(z.evalf()) - float(known_sing.evalf())) < 1e-8:
415
+ if known_sing not in factor_dict[key]["singularities"]:
416
+ factor_dict[key]["singularities"].append(known_sing)
417
+ break
418
+ except:
419
+ pass
420
+ except:
421
+ pass
422
+
423
+ # Convert grouped factors to list
424
+ # Include ALL factors, even those without zeros/singularities
425
+ for factor_info in factor_dict.values():
426
+ all_roots = factor_info["zeros"] + factor_info["singularities"]
427
+ # Always add the factor, even if it has no roots
428
+ factors.append(
429
+ {
430
+ "expression": factor_info["expression"],
431
+ "exponent": factor_info["exponent"],
432
+ "roots": all_roots if all_roots else [],
433
+ }
434
+ )
435
+ else:
436
+ # Not a product or power, show as single factor
437
+ all_roots = zeros + singularities
438
+ factors.append(
439
+ {
440
+ "expression": factored,
441
+ "exponent": 1,
442
+ "roots": all_roots if all_roots else [],
443
+ }
444
+ )
445
+ except:
446
+ # Fallback: just use the original expression
447
+ all_roots = zeros + singularities
448
+ factors.append(
449
+ {
450
+ "expression": expr,
451
+ "exponent": 1,
452
+ "roots": all_roots if all_roots else [],
453
+ }
454
+ )
455
+
456
+ return factors
457
+
458
+
459
+ def sort_factors(factors):
460
+ def get_numeric_root(factor):
461
+ # Handle both old format ("root") and new format ("roots")
462
+ if "roots" in factor and factor["roots"]:
463
+ # For new format, use the first root for sorting
464
+ root = factor["roots"][0]
465
+ elif "root" in factor:
466
+ root = factor.get("root")
467
+ else:
468
+ # No roots - sort to end
469
+ return np.inf
470
+
471
+ if root == -np.inf or root is None:
472
+ return -np.inf
473
+ try:
474
+ # Try to convert symbolic roots to float for comparison
475
+ return float(root.evalf())
476
+ except (AttributeError, TypeError):
477
+ try:
478
+ return float(root)
479
+ except:
480
+ return -np.inf
481
+
482
+ factors = sorted(factors, key=get_numeric_root)
483
+ return factors
484
+
485
+
486
+ def draw_factors(
487
+ f,
488
+ factors,
489
+ roots,
490
+ root_positions,
491
+ ax,
492
+ color_pos,
493
+ color_neg,
494
+ x,
495
+ dy=-1,
496
+ dx=0.02,
497
+ ):
498
+ x_min = -0.05
499
+ x_max = 1.05
500
+ # Draw horizontal sign lines for each factor
501
+ for i, factor in enumerate(factors):
502
+ expression = factor.get("expression")
503
+ exponent = factor.get("exponent")
504
+
505
+ # Handle both old format (single "root") and new format (multiple "roots")
506
+ factor_roots = (
507
+ factor.get("roots", [factor.get("root")])
508
+ if factor.get("root") is not None or factor.get("roots")
509
+ else []
510
+ )
511
+
512
+ # Use LaTeX rendering for proper mathematical notation
513
+ try:
514
+ expression_latex = sp.latex(expression)
515
+ except:
516
+ # Fallback to string representation if latex fails
517
+ expression_latex = str(expression)
518
+
519
+ if exponent > 1:
520
+ s = f"$({expression_latex})^{{{exponent}}}$"
521
+ else:
522
+ s = f"${expression_latex}$"
523
+
524
+ plt.text(
525
+ x=-0.1,
526
+ y=(i + 1) * dy,
527
+ s=s,
528
+ fontsize=16,
529
+ ha="right",
530
+ va="center",
531
+ )
532
+
533
+ # If no real roots (constant factor)
534
+ if -np.inf in factor_roots or not factor_roots:
535
+ y_value = sp.sympify(expression).evalf(subs={x: 0})
536
+ if y_value > 0:
537
+ ax.plot(
538
+ [x_min, x_max],
539
+ [(i + 1) * dy, (i + 1) * dy],
540
+ color=color_pos,
541
+ linestyle="-",
542
+ lw=2,
543
+ )
544
+ else:
545
+ ax.plot(
546
+ [x_min, x_max],
547
+ [(i + 1) * dy, (i + 1) * dy],
548
+ color=color_neg,
549
+ linestyle="--",
550
+ lw=2,
551
+ )
552
+ else:
553
+ # Sort roots for drawing
554
+ sorted_roots = sorted(
555
+ [r for r in factor_roots if r != -np.inf],
556
+ key=lambda r: float(r.evalf()),
557
+ )
558
+
559
+ # Determine if even exponent (doesn't change sign) or odd (changes sign)
560
+ is_even_exponent = exponent % 2 == 0
561
+
562
+ # Create the full expression with exponent for evaluation
563
+ expr = expression
564
+ if exponent > 1:
565
+ full_expr = expr**exponent
566
+ else:
567
+ full_expr = expr
568
+
569
+ # Draw segments between roots
570
+ # We need to evaluate signs in each interval
571
+ all_positions = [x_min] + [root_positions[r] for r in sorted_roots] + [x_max]
572
+
573
+ for j in range(len(all_positions) - 1):
574
+ left_pos = all_positions[j]
575
+ right_pos = all_positions[j + 1]
576
+
577
+ # Add gap around roots (except at boundaries)
578
+ if j > 0: # Not the leftmost segment
579
+ left_pos += dx
580
+ if j < len(all_positions) - 2: # Not the rightmost segment
581
+ right_pos -= dx
582
+
583
+ # Map position back to actual x value for evaluation
584
+ # Position is normalized 0-1, map to actual domain
585
+ if j < len(sorted_roots):
586
+ if j == 0:
587
+ # Before first root
588
+ test_x = float(sorted_roots[0].evalf()) - 1
589
+ else:
590
+ # Between roots
591
+ test_x = (
592
+ float(sorted_roots[j - 1].evalf()) + float(sorted_roots[j].evalf())
593
+ ) / 2
594
+ else:
595
+ # After last root
596
+ test_x = float(sorted_roots[-1].evalf()) + 1
597
+
598
+ # Evaluate sign at test point
599
+ try:
600
+ y_val = sp.sympify(full_expr).evalf(subs={x: test_x})
601
+ is_positive = y_val > 0
602
+ color = color_pos if is_positive else color_neg
603
+ linestyle = "-" if is_positive else "--"
604
+ except:
605
+ # Fallback
606
+ color = color_pos
607
+ linestyle = "-"
608
+
609
+ # Draw segment
610
+ ax.plot(
611
+ [left_pos, right_pos],
612
+ [(i + 1) * dy, (i + 1) * dy],
613
+ color=color,
614
+ linestyle=linestyle,
615
+ lw=2,
616
+ )
617
+
618
+ # Draw markers at roots
619
+ for root in sorted_roots:
620
+ root_pos = root_positions[root]
621
+
622
+ # Check if it's a zero or singularity
623
+ try:
624
+ f_at_root = str(f.subs(x, root))
625
+ is_singularity = f_at_root == "zoo" or "zoo" in f_at_root
626
+ except:
627
+ is_singularity = False
628
+
629
+ if is_singularity:
630
+ plt.text(
631
+ x=root_pos + 0.005,
632
+ y=(i + 1) * dy,
633
+ s=f"$\\times$",
634
+ fontsize=24,
635
+ ha="center",
636
+ va="center",
637
+ )
638
+ else:
639
+ plt.text(
640
+ x=root_pos,
641
+ y=(i + 1) * dy,
642
+ s=f"$0$",
643
+ fontsize=20,
644
+ ha="center",
645
+ va="center",
646
+ )
647
+
648
+
649
+ def draw_function(
650
+ factors,
651
+ roots,
652
+ root_positions,
653
+ ax,
654
+ color_pos,
655
+ color_neg,
656
+ x,
657
+ f,
658
+ fn_name=None,
659
+ include_factors=True,
660
+ dy=-1,
661
+ dx=0.02,
662
+ ):
663
+
664
+ x_min = -0.05
665
+ x_max = 1.05
666
+
667
+ if include_factors:
668
+ y = (len(factors) + 1) * dy
669
+ else:
670
+ y = dy
671
+ plt.text(
672
+ x=-0.1,
673
+ y=y,
674
+ s=f"${fn_name}$" if fn_name else f"$f({str(x)})$",
675
+ fontsize=16,
676
+ ha="right",
677
+ va="center",
678
+ )
679
+
680
+ # Case 1: the polynomial has no roots.
681
+ if len(roots) == 0:
682
+ x0 = 0
683
+ y0 = sp.sympify(f).evalf(subs={x: x0})
684
+
685
+ if y0 > 0:
686
+ ax.plot(
687
+ [x_min, x_max],
688
+ [y, y],
689
+ color=color_pos,
690
+ linestyle="-",
691
+ lw=2,
692
+ )
693
+ else:
694
+ ax.plot(
695
+ [x_min, x_max],
696
+ [y, y],
697
+ color=color_neg,
698
+ linestyle="--",
699
+ lw=2,
700
+ )
701
+
702
+ return None
703
+
704
+ intervals = []
705
+ interval_positions = []
706
+
707
+ # Intervals before first root
708
+ intervals.append((roots[0] - 1, roots[0] - 0.1))
709
+ interval_positions.append((x_min, root_positions[roots[0]] - dx))
710
+
711
+ # Intervals between roots
712
+ for i in range(len(roots) - 1):
713
+ intervals.append((roots[i] + 0.1, roots[i + 1] - 0.1))
714
+ interval_positions.append(
715
+ (root_positions[roots[i]] + dx, root_positions[roots[i + 1]] - dx)
716
+ )
717
+
718
+ # Interval after last root
719
+ intervals.append((roots[-1] + 0.1, roots[-1] + 1))
720
+ interval_positions.append((root_positions[roots[-1]] + dx, x_max))
721
+
722
+ for i, (x0_interval, pos_interval) in enumerate(zip(intervals, interval_positions)):
723
+ x0 = (x0_interval[0] + x0_interval[1]) / 2
724
+ y0 = sp.sympify(f).evalf(subs={x: x0})
725
+
726
+ if y0 > 0:
727
+ ax.plot(
728
+ [pos_interval[0], pos_interval[1]],
729
+ [y, y],
730
+ color=color_pos,
731
+ linestyle="-",
732
+ lw=2,
733
+ )
734
+ else:
735
+ ax.plot(
736
+ [pos_interval[0], pos_interval[1]],
737
+ [y, y],
738
+ color=color_neg,
739
+ linestyle="--",
740
+ lw=2,
741
+ )
742
+
743
+ # Plot zeros at root positions
744
+ for root in roots:
745
+ root_pos = root_positions[root]
746
+ if str(f.subs(x, root)) != "zoo":
747
+ plt.text(
748
+ x=root_pos,
749
+ y=y,
750
+ s=f"$0$",
751
+ fontsize=20,
752
+ ha="center",
753
+ va="center",
754
+ )
755
+ else:
756
+ plt.text(
757
+ x=root_pos + 0.005,
758
+ y=y,
759
+ s=f"$\\times$",
760
+ fontsize=24,
761
+ ha="center",
762
+ va="center",
763
+ )
764
+
765
+
766
+ def draw_vertical_lines(
767
+ roots,
768
+ root_positions,
769
+ factors,
770
+ ax,
771
+ include_factors=True,
772
+ dy=-1,
773
+ ):
774
+ # Draw vertical lines to separate regions
775
+ offset_dy = 0.2
776
+
777
+ if include_factors:
778
+ # Collect y positions of zeros from factors
779
+ y_zeros_dict = {}
780
+ for i, factor in enumerate(factors):
781
+ # Handle both old format ("root") and new format ("roots")
782
+ factor_roots = (
783
+ factor.get("roots", [factor.get("root")])
784
+ if factor.get("root") is not None or factor.get("roots")
785
+ else []
786
+ )
787
+
788
+ for root in factor_roots:
789
+ if root != -np.inf:
790
+ y_zero = (i + 1) * dy
791
+ if root in y_zeros_dict:
792
+ y_zeros_dict[root].append(y_zero)
793
+ else:
794
+ y_zeros_dict[root] = [y_zero]
795
+ # Add y position of zero from function
796
+ y_function = (len(factors) + 1) * dy
797
+ else:
798
+ y_zeros_dict = {}
799
+ y_function = dy
800
+
801
+ y_min = -0.4
802
+ y_max = y_function + 0.5
803
+
804
+ for root in roots:
805
+ root_pos = root_positions[root]
806
+ # Collect y positions where zeros are placed at this root
807
+ zero_y_positions = []
808
+ # From factors
809
+ if root in y_zeros_dict:
810
+ zero_y_positions.extend(y_zeros_dict[root])
811
+ # From function
812
+ zero_y_positions.append(y_function)
813
+ # Now adjust zero_y_positions to include offset_dy
814
+ y_positions = [y_min]
815
+ for y_zero in zero_y_positions:
816
+ y_positions.extend([y_zero - offset_dy, y_zero + offset_dy])
817
+ y_positions.append(y_max)
818
+ y_positions = sorted(y_positions)
819
+
820
+ # Now plot segments between pairs
821
+ for i in range(1, len(y_positions) - 1):
822
+ y_start = y_positions[i]
823
+ y_end = y_positions[i + 1]
824
+ # Skip the segments around the zeros
825
+ if (i % 2) == 0:
826
+ if y_end - y_start > 0:
827
+ ax.plot(
828
+ [root_pos, root_pos],
829
+ [y_start, y_end],
830
+ color="black",
831
+ linestyle="-",
832
+ lw=1,
833
+ )
834
+
835
+
836
+ def make_axis(x):
837
+ fig, ax = plt.subplots()
838
+
839
+ # Remove y-axis spines
840
+ ax.spines["left"].set_color("none") # Remove the left y-axis
841
+ ax.spines["right"].set_color("none") # Remove the right y-axis
842
+
843
+ # Move the x-axis to y=0
844
+ ax.spines["bottom"].set_position("zero") # Position the bottom x-axis at y=0
845
+ ax.spines["top"].set_color("none") # Remove the top x-axis
846
+
847
+ # Move x-axis ticks and labels to the top
848
+ ax.xaxis.set_ticks_position("top") # Move ticks to the top
849
+ ax.xaxis.set_label_position("top") # Move labels to the top
850
+ ax.tick_params(
851
+ axis="x",
852
+ which="both", # Hide bottom ticks and labels
853
+ bottom=False,
854
+ labelbottom=False,
855
+ length=10,
856
+ )
857
+
858
+ # Attach arrow to the right end of the x-axis
859
+ ax.plot(1, 0, ">k", transform=ax.get_yaxis_transform(), clip_on=False)
860
+
861
+ # Label the x-axis
862
+ ax.set_xlabel(f"${str(x)}$", fontsize=16, loc="right")
863
+
864
+ # Remove tick labels on y-axis
865
+ plt.yticks([])
866
+
867
+ # Set x-limits
868
+ ax.set_xlim(-0.05, 1.05)
869
+
870
+ return fig, ax
871
+
872
+
873
+ def plot(
874
+ f,
875
+ x=None,
876
+ fn_name=None,
877
+ color=True,
878
+ include_factors=True,
879
+ generic_labels=False,
880
+ small_figsize=False,
881
+ figsize=None,
882
+ domain=None,
883
+ ):
884
+ """Draws a sign chart for a function f (polynomial, rational, or transcendental).
885
+
886
+ Args:
887
+ f (sp.Expr, str):
888
+ Function expression. May be a sympy.Expr or str. Supports polynomials,
889
+ rational functions, and transcendental functions (sin, cos, exp, log, etc.).
890
+ x (sp.symbols, str, optional):
891
+ Variable in the function
892
+ fn_name (str, optional):
893
+ Name of the function. Defaults `None`.
894
+ color (bool, optional):
895
+ Enables coloring of sign chart. Default: `True`.
896
+ include_factors (bool, optional):
897
+ Includes all linear factors of f(x) for polynomials. For non-polynomial
898
+ functions, this shows the function name only. Default: `True`.
899
+ generic_label (bool, optional):
900
+ Uses generic labels for roots: x_1, x_2, ..., x_N. Default: `False`.
901
+ small_figsize (bool, optional):
902
+ Enables rescaling of the figure for a smaller figure size. Default: `False`.
903
+ domain (tuple, optional):
904
+ Domain (x_min, x_max) for searching zeros numerically in transcendental functions.
905
+ If None, uses a default range or symbolic solving only. Example: (-10, 10)
906
+
907
+ Returns:
908
+ fig (plt.figure)
909
+ matplotlib figure.
910
+ ax (plt.Axis)
911
+ matplotlib axis.
912
+ """
913
+ if isinstance(f, str):
914
+ f = sp.sympify(f)
915
+
916
+ original_variable = list(f.free_symbols)[0]
917
+ x = sp.symbols(str(original_variable), real=True)
918
+ f = f.subs(original_variable, x)
919
+
920
+ if color:
921
+ color_pos = "red"
922
+ color_neg = "blue"
923
+ else:
924
+ color_pos = color_neg = "black"
925
+
926
+ # Determine function type and extract zeros/singularities
927
+ is_polynomial = f.is_polynomial()
928
+ is_rational = False
929
+
930
+ if is_polynomial:
931
+ # Use existing polynomial factorization
932
+ factors = get_factors(polynomial=f, x=x)
933
+ factors = sort_factors(factors)
934
+ else:
935
+ # Check if it's a rational function
936
+ try:
937
+ numer, denom = f.as_numer_denom()
938
+ if denom != 1 and denom.is_polynomial() and numer.is_polynomial():
939
+ is_rational = True
940
+ # Handle as rational function
941
+ p_factors = get_factors(polynomial=numer, x=x) if numer != 1 else []
942
+ q_factors = get_factors(polynomial=denom, x=x) if denom != 1 else []
943
+ factors = p_factors + q_factors
944
+ factors = sort_factors(factors)
945
+ else:
946
+ # General transcendental or composite function
947
+ is_rational = False
948
+ # Use general zero finding
949
+ result = get_zeros_and_singularities(f, x, domain=domain)
950
+ zeros = result["zeros"]
951
+ singularities = result["singularities"]
952
+
953
+ # Extract factors from transcendental function
954
+ factors = get_transcendental_factors(f, x, zeros, singularities)
955
+ factors = sort_factors(factors)
956
+ # Now we can show factors for transcendental functions too!
957
+ except:
958
+ # Fallback: general function
959
+ result = get_zeros_and_singularities(f, x, domain=domain)
960
+ zeros = result["zeros"]
961
+ singularities = result["singularities"]
962
+
963
+ # Extract factors
964
+ factors = get_transcendental_factors(f, x, zeros, singularities)
965
+ factors = sort_factors(factors)
966
+
967
+ # Create figure
968
+ fig, ax = make_axis(x)
969
+
970
+ # Extract roots - handle both old format (single "root") and new format (multiple "roots")
971
+ roots = []
972
+ for factor in factors:
973
+ if "roots" in factor and factor["roots"]:
974
+ # New format: multiple roots per factor
975
+ for r in factor["roots"]:
976
+ if r != -np.inf and r not in roots:
977
+ roots.append(r)
978
+ elif "root" in factor and factor["root"] != -np.inf:
979
+ # Old format: single root per factor
980
+ if factor["root"] not in roots:
981
+ roots.append(factor["root"])
982
+
983
+ # Sort roots
984
+ roots = sorted(roots, key=lambda r: float(r.evalf()))
985
+
986
+ # Map roots to positions
987
+ num_roots = len(roots)
988
+ x_min = -0.05
989
+ x_max = 1.05
990
+ positions = np.linspace(0, 1, num_roots + 2)[1:-1] # Exclude 0 and 1
991
+ root_positions = dict(zip(roots, positions))
992
+
993
+ # Set tick marks for roots of the polynomial
994
+ plt.xticks(
995
+ ticks=positions,
996
+ labels=[
997
+ f"${sp.latex(root)}$" if not generic_labels else f"$x_{i + 1}$"
998
+ for i, root in enumerate(roots)
999
+ ],
1000
+ fontsize=16,
1001
+ )
1002
+
1003
+ # Draw factors
1004
+ if include_factors:
1005
+ draw_factors(f, factors, roots, root_positions, ax, color_pos, color_neg, x)
1006
+
1007
+ # Draw sign lines for function
1008
+ draw_function(
1009
+ factors,
1010
+ roots,
1011
+ root_positions,
1012
+ ax,
1013
+ color_pos,
1014
+ color_neg,
1015
+ x,
1016
+ f,
1017
+ fn_name,
1018
+ include_factors,
1019
+ )
1020
+
1021
+ # Remove tick labels on y-axis
1022
+ plt.yticks([])
1023
+
1024
+ plt.xlim(x_min, x_max)
1025
+
1026
+ if include_factors:
1027
+ if figsize:
1028
+ fig.set_size_inches(figsize)
1029
+ else:
1030
+ fig.set_size_inches(8, 2 + int(0.7 * len(factors)))
1031
+
1032
+ elif small_figsize:
1033
+ fig.set_size_inches(4, 1.5)
1034
+ else:
1035
+ fig.set_size_inches(8, 2)
1036
+
1037
+ draw_vertical_lines(roots, root_positions, factors, ax, include_factors)
1038
+
1039
+ plt.tight_layout()
1040
+
1041
+ return fig, ax
1042
+
1043
+
1044
+ # ------------------------------------
1045
+ # Utilities
1046
+ # ------------------------------------
1047
+
1048
+
1049
+ def _hash_key(*parts) -> str:
1050
+ """
1051
+ Generate a short hash key from multiple parts.
1052
+
1053
+ Used for creating unique filenames based on function content.
1054
+
1055
+ Args:
1056
+ *parts: Variable number of parts to hash
1057
+
1058
+ Returns:
1059
+ str: 12-character hex hash
1060
+ """
1061
+ h = hashlib.sha1()
1062
+ for p in parts:
1063
+ if p is None:
1064
+ p = "__NONE__"
1065
+ h.update(str(p).encode("utf-8"))
1066
+ h.update(b"||")
1067
+ return h.hexdigest()[:12]
1068
+
1069
+
1070
+ def _safe_literal(val: str):
1071
+ """
1072
+ Safely evaluate a string as a Python literal.
1073
+
1074
+ Args:
1075
+ val: String to evaluate
1076
+
1077
+ Returns:
1078
+ Evaluated value or None if evaluation fails
1079
+ """
1080
+ import ast
1081
+
1082
+ try:
1083
+ return ast.literal_eval(val)
1084
+ except Exception:
1085
+ return None
1086
+
1087
+
1088
+ def _parse_bool(val, default: bool | None = None) -> bool | None:
1089
+ """
1090
+ Parse a value as a boolean.
1091
+
1092
+ Args:
1093
+ val: Value to parse (bool, str, or None)
1094
+ default: Default value if parsing fails
1095
+
1096
+ Returns:
1097
+ bool | None: Parsed boolean value or default
1098
+ """
1099
+ if val is None:
1100
+ return default
1101
+ if isinstance(val, bool):
1102
+ return val
1103
+ s = str(val).strip().lower()
1104
+ if s == "":
1105
+ return True
1106
+ if s in {"true", "yes", "on", "1"}:
1107
+ return True
1108
+ if s in {"false", "no", "off", "0"}:
1109
+ return False
1110
+ return default
1111
+
1112
+
1113
+ def _parse_tuple(val, element_type=float):
1114
+ """
1115
+ Parse a value as a tuple.
1116
+
1117
+ Args:
1118
+ val: Value to parse (string like "(1, 2)" or tuple)
1119
+ element_type: Type to convert elements to (default: float)
1120
+
1121
+ Returns:
1122
+ tuple or None if parsing fails
1123
+ """
1124
+ if val is None:
1125
+ return None
1126
+ if isinstance(val, (list, tuple)):
1127
+ try:
1128
+ return tuple(element_type(x) for x in val)
1129
+ except:
1130
+ return None
1131
+ # Try to parse as literal
1132
+ lit = _safe_literal(str(val))
1133
+ if isinstance(lit, (list, tuple)):
1134
+ try:
1135
+ return tuple(element_type(x) for x in lit)
1136
+ except:
1137
+ return None
1138
+ return None
1139
+
1140
+
1141
+ def _strip_root_svg_size(svg_text: str) -> str:
1142
+ """
1143
+ Remove width/height attributes from the root <svg> tag.
1144
+
1145
+ This allows CSS to control the SVG size.
1146
+
1147
+ Args:
1148
+ svg_text: Raw SVG content
1149
+
1150
+ Returns:
1151
+ str: SVG with width/height removed from root tag
1152
+ """
1153
+
1154
+ def repl(m):
1155
+ tag = m.group(0)
1156
+ tag = re.sub(r'\swidth="[^"]+"', "", tag)
1157
+ tag = re.sub(r'\sheight="[^"]+"', "", tag)
1158
+ return tag
1159
+
1160
+ return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
1161
+
1162
+
1163
+ def _rewrite_ids(txt: str, prefix: str) -> str:
1164
+ """
1165
+ Rewrite all id attributes in SVG to avoid conflicts.
1166
+
1167
+ When multiple SVGs are on the same page, id conflicts can cause
1168
+ rendering issues. This function prefixes all ids with a unique prefix.
1169
+
1170
+ Args:
1171
+ txt: SVG content
1172
+ prefix: Prefix to add to all ids
1173
+
1174
+ Returns:
1175
+ str: SVG with rewritten ids
1176
+ """
1177
+ ids = re.findall(r'\bid="([^"]+)"', txt)
1178
+ if not ids:
1179
+ return txt
1180
+ skip_prefixes = (
1181
+ "DejaVu",
1182
+ "CM",
1183
+ "STIX",
1184
+ "Nimbus",
1185
+ "Bitstream",
1186
+ "Arial",
1187
+ "Times",
1188
+ "Helvetica",
1189
+ )
1190
+ mapping = {}
1191
+ for i in ids:
1192
+ if i.startswith(skip_prefixes):
1193
+ continue
1194
+ mapping[i] = f"{prefix}{i}"
1195
+ if not mapping:
1196
+ return txt
1197
+
1198
+ def repl_id(m: re.Match) -> str:
1199
+ old = m.group(1)
1200
+ new = mapping.get(old, old)
1201
+ return f'id="{new}"'
1202
+
1203
+ txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
1204
+
1205
+ def repl_url(m: re.Match) -> str:
1206
+ old = m.group(1).strip()
1207
+ new = mapping.get(old, old)
1208
+ return f"url(#{new})"
1209
+
1210
+ txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
1211
+
1212
+ def repl_href(m: re.Match) -> str:
1213
+ attr = m.group(1)
1214
+ quote = m.group(2)
1215
+ old = m.group(3).strip()
1216
+ new = mapping.get(old, old)
1217
+ return f"{attr}={quote}#{new}{quote}"
1218
+
1219
+ txt = re.sub(r'(xlink:href|href)\s*=\s*(["\'])#\s*([^"\']+)\s*\2', repl_href, txt)
1220
+ return txt
1221
+
1222
+
1223
+ # ------------------------------------
1224
+ # Sphinx Directive
1225
+ # ------------------------------------
1226
+
1227
+
1228
+ class SignChart2Directive(SphinxDirective):
1229
+ """
1230
+ Sphinx directive for generating improved sign charts with embedded plotting.
1231
+
1232
+ This directive supports polynomial, rational, and transcendental functions with
1233
+ extensive customization options.
1234
+
1235
+ Options:
1236
+ function (required): The function expression and optional label
1237
+ Format: "expression, label" or ("expression", "label")
1238
+ Example: "sin(x)*cos(x), f(x)"
1239
+ factors (optional): Whether to show factored form (default: true)
1240
+ domain (optional): Domain tuple (xmin, xmax) for numerical zero finding
1241
+ Example: "(-10, 10)" or (-10, 10)
1242
+ color (optional): Enable colored sign chart (default: true)
1243
+ generic_labels (optional): Use x₁, x₂ labels instead of actual values (default: false)
1244
+ small_figsize (optional): Use compact figure size (default: false)
1245
+ figsize (optional): Custom figure size as tuple (width, height)
1246
+ Example: "(10, 4)" or (10, 4)
1247
+ width (optional): Width of the chart (e.g., "100%", "500px", "500")
1248
+ align (optional): Alignment ("left", "center", "right")
1249
+ class (optional): Additional CSS classes
1250
+ name (optional): Reference name for the figure
1251
+ nocache (optional): Force regeneration of the chart
1252
+ debug (optional): Keep original SVG dimensions and ids
1253
+ alt (optional): Alt text for accessibility (default: "Fortegnsskjema")
1254
+
1255
+ Example:
1256
+ ```{signchart-2}
1257
+ ---
1258
+ function: sin(x)*cos(x), f(x)
1259
+ domain: (-3.14, 3.14)
1260
+ factors: true
1261
+ width: 100%
1262
+ ---
1263
+ Sign chart for f(x) = sin(x)·cos(x)
1264
+ ```
1265
+ """
1266
+
1267
+ has_content = True
1268
+ required_arguments = 0
1269
+ option_spec = {
1270
+ # presentation / misc
1271
+ "width": directives.length_or_percentage_or_unitless,
1272
+ "align": lambda a: directives.choice(a, ["left", "center", "right"]),
1273
+ "class": directives.class_option,
1274
+ "name": directives.unchanged,
1275
+ "nocache": directives.flag,
1276
+ "debug": directives.flag,
1277
+ "alt": directives.unchanged,
1278
+ # specific options
1279
+ "function": directives.unchanged_required,
1280
+ "factors": directives.unchanged,
1281
+ "domain": directives.unchanged,
1282
+ "color": directives.unchanged,
1283
+ "generic_labels": directives.unchanged,
1284
+ "small_figsize": directives.unchanged,
1285
+ "figsize": directives.unchanged,
1286
+ }
1287
+
1288
+ def _parse_kv_block(self) -> Tuple[Dict[str, Any], int]:
1289
+ """
1290
+ Parse YAML-style key-value block from directive content.
1291
+
1292
+ Supports two formats:
1293
+ 1. YAML front-matter style with --- delimiters
1294
+ 2. Simple key: value pairs at the start
1295
+
1296
+ Returns:
1297
+ tuple: (dict of parsed options, index where caption starts)
1298
+ """
1299
+ lines = list(self.content)
1300
+ scalars: Dict[str, Any] = {}
1301
+ idx = 0
1302
+ if lines and lines[0].strip() == "---":
1303
+ idx = 1
1304
+ while idx < len(lines) and lines[idx].strip() != "---":
1305
+ line = lines[idx].rstrip()
1306
+ if not line.strip():
1307
+ idx += 1
1308
+ continue
1309
+ m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
1310
+ if m:
1311
+ scalars[m.group(1)] = m.group(2)
1312
+ idx += 1
1313
+ if idx < len(lines) and lines[idx].strip() == "---":
1314
+ idx += 1
1315
+ while idx < len(lines) and not lines[idx].strip():
1316
+ idx += 1
1317
+ return scalars, idx
1318
+
1319
+ caption_start = 0
1320
+ for i, line in enumerate(lines):
1321
+ if not line.strip():
1322
+ caption_start = i + 1
1323
+ continue
1324
+ m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
1325
+ if m:
1326
+ scalars[m.group(1)] = m.group(2)
1327
+ caption_start = i + 1
1328
+ else:
1329
+ break
1330
+ return scalars, caption_start
1331
+
1332
+ def run(self): # noqa: C901
1333
+ """
1334
+ Generate the sign chart.
1335
+
1336
+ Returns:
1337
+ list: List of docutils nodes (figure containing SVG)
1338
+ """
1339
+ env = self.state.document.settings.env
1340
+ app = env.app
1341
+
1342
+ scalars, caption_idx = self._parse_kv_block()
1343
+ merged: Dict[str, Any] = {**scalars, **self.options}
1344
+
1345
+ func_raw = merged.get("function")
1346
+ if not func_raw:
1347
+ return [
1348
+ self.state_machine.reporter.error(
1349
+ "Directive 'signchart-2' requires 'function:' option",
1350
+ line=self.lineno,
1351
+ )
1352
+ ]
1353
+
1354
+ # Parse function as either (expr, label) literal or "expr, label"
1355
+ f_expr = None
1356
+ f_name = None
1357
+ lit = _safe_literal(str(func_raw))
1358
+ if isinstance(lit, (list, tuple)) and len(lit) >= 1:
1359
+ f_expr = str(lit[0]).strip()
1360
+ if len(lit) > 1:
1361
+ f_name = str(lit[1]).strip() or None
1362
+ else:
1363
+ s = str(func_raw)
1364
+ if "," in s:
1365
+ expr, label = s.split(",", 1)
1366
+ f_expr = expr.strip()
1367
+ label = label.strip()
1368
+ f_name = label or None
1369
+ else:
1370
+ f_expr = s.strip()
1371
+ f_name = None
1372
+
1373
+ # Parse options
1374
+ include_factors = _parse_bool(merged.get("factors"), default=True)
1375
+ use_color = _parse_bool(merged.get("color"), default=True)
1376
+ generic_labels = _parse_bool(merged.get("generic_labels"), default=False)
1377
+ small_figsize = _parse_bool(merged.get("small_figsize"), default=False)
1378
+ explicit_name = merged.get("name")
1379
+ debug_mode = "debug" in merged
1380
+
1381
+ # Parse domain
1382
+ domain_val = merged.get("domain")
1383
+ custom_domain = _parse_tuple(domain_val, float) if domain_val else None
1384
+
1385
+ # Parse figsize
1386
+ figsize_val = merged.get("figsize")
1387
+ custom_figsize = _parse_tuple(figsize_val, float) if figsize_val else None
1388
+
1389
+ # Hash includes all plot parameters
1390
+ content_hash = _hash_key(
1391
+ f_expr,
1392
+ f_name or "",
1393
+ int(bool(include_factors)),
1394
+ int(bool(use_color)),
1395
+ int(bool(generic_labels)),
1396
+ int(bool(small_figsize)),
1397
+ str(custom_domain) if custom_domain else "",
1398
+ str(custom_figsize) if custom_figsize else "",
1399
+ )
1400
+ base_name = explicit_name or f"signchart2_{content_hash}"
1401
+
1402
+ rel_dir = os.path.join("_static", "signchart")
1403
+ abs_dir = os.path.join(app.srcdir, rel_dir)
1404
+ os.makedirs(abs_dir, exist_ok=True)
1405
+ svg_name = f"{base_name}.svg"
1406
+ abs_svg = os.path.join(abs_dir, svg_name)
1407
+
1408
+ regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
1409
+ if regenerate:
1410
+ try:
1411
+ # Render using embedded plot function
1412
+ plot_kwargs = {
1413
+ "f": f_expr,
1414
+ "fn_name": f_name or None,
1415
+ "include_factors": bool(include_factors),
1416
+ "color": bool(use_color),
1417
+ "generic_labels": bool(generic_labels),
1418
+ "small_figsize": bool(small_figsize),
1419
+ }
1420
+ if custom_domain is not None:
1421
+ plot_kwargs["domain"] = custom_domain
1422
+ if custom_figsize is not None:
1423
+ plot_kwargs["figsize"] = custom_figsize
1424
+
1425
+ fig, ax = plot(**plot_kwargs)
1426
+ fig.savefig(abs_svg, format="svg", bbox_inches="tight")
1427
+ plt.close(fig)
1428
+ except Exception as e:
1429
+ return [
1430
+ self.state_machine.reporter.error(
1431
+ f"Error generating sign chart: {e}",
1432
+ line=self.lineno,
1433
+ )
1434
+ ]
1435
+
1436
+ if not os.path.exists(abs_svg):
1437
+ return [
1438
+ self.state_machine.reporter.error(
1439
+ "signchart-2: SVG file missing.", line=self.lineno
1440
+ )
1441
+ ]
1442
+
1443
+ env.note_dependency(abs_svg)
1444
+ # copy into build _static
1445
+ try:
1446
+ out_static = os.path.join(app.outdir, "_static", "signchart")
1447
+ os.makedirs(out_static, exist_ok=True)
1448
+ shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
1449
+ except Exception:
1450
+ pass
1451
+
1452
+ try:
1453
+ raw_svg = open(abs_svg, "r", encoding="utf-8").read()
1454
+ except Exception as e:
1455
+ return [
1456
+ self.state_machine.reporter.error(
1457
+ f"signchart-2 inline: could not read SVG: {e}", line=self.lineno
1458
+ )
1459
+ ]
1460
+
1461
+ if not debug_mode and "viewBox" in raw_svg:
1462
+ raw_svg = _strip_root_svg_size(raw_svg)
1463
+
1464
+ if not debug_mode:
1465
+ raw_svg = _rewrite_ids(raw_svg, f"sgc2_{content_hash}_{uuid.uuid4().hex[:6]}_")
1466
+
1467
+ alt_default = "Fortegnsskjema"
1468
+ alt = merged.get("alt", alt_default)
1469
+
1470
+ width_opt = merged.get("width")
1471
+ percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
1472
+
1473
+ def _augment(m):
1474
+ """Add classes, aria-label, and width styling to root SVG tag."""
1475
+ tag = m.group(0)
1476
+ if "class=" not in tag:
1477
+ tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
1478
+ else:
1479
+ tag = tag.replace('class="', 'class="graph-inline-svg ')
1480
+ if alt and "aria-label=" not in tag:
1481
+ tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
1482
+ if width_opt:
1483
+ if percent:
1484
+ wval = width_opt.strip()
1485
+ else:
1486
+ wval = width_opt.strip()
1487
+ if wval.isdigit():
1488
+ wval += "px"
1489
+ style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
1490
+ if "style=" in tag:
1491
+ tag = re.sub(
1492
+ r'style="([^"]*)"',
1493
+ lambda mm: f'style="{mm.group(1)}; {style_frag}"',
1494
+ tag,
1495
+ count=1,
1496
+ )
1497
+ else:
1498
+ tag = tag[:-1] + f' style="{style_frag}"' + ">"
1499
+ return tag
1500
+
1501
+ raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
1502
+
1503
+ figure = nodes.figure()
1504
+ figure.setdefault("classes", []).extend(["adaptive-figure", "signchart-figure", "no-click"])
1505
+ raw_node = nodes.raw("", raw_svg, format="html")
1506
+ raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
1507
+ figure += raw_node
1508
+
1509
+ extra_classes = merged.get("class")
1510
+ if extra_classes:
1511
+ figure["classes"].extend(extra_classes)
1512
+ figure["align"] = merged.get("align", "center")
1513
+
1514
+ caption_lines = list(self.content)[caption_idx:]
1515
+ while caption_lines and not caption_lines[0].strip():
1516
+ caption_lines.pop(0)
1517
+ if caption_lines:
1518
+ caption = nodes.caption()
1519
+ caption_text = "\n".join(caption_lines)
1520
+ # Parse as inline text to support math while avoiding extra paragraph nodes
1521
+ parsed_nodes, messages = self.state.inline_text(caption_text, self.lineno)
1522
+ caption.extend(parsed_nodes)
1523
+ figure += caption
1524
+
1525
+ if explicit_name := merged.get("name"):
1526
+ self.add_name(figure)
1527
+ return [figure]
1528
+
1529
+
1530
+ def setup(app):
1531
+ """
1532
+ Setup function to register the directive with Sphinx.
1533
+
1534
+ This function is called automatically by Sphinx when the extension is loaded.
1535
+ It registers both 'signchart-2' and 'signchart2' directives for compatibility.
1536
+
1537
+ Args:
1538
+ app: The Sphinx application instance
1539
+
1540
+ Returns:
1541
+ dict: Extension metadata including version and parallel processing flags
1542
+ """
1543
+ app.add_directive("signchart-2", SignChart2Directive)
1544
+ app.add_directive("signchart2", SignChart2Directive)
1545
+ return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}