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,3758 @@
|
|
|
1
|
+
r"""Plot directive — full reference (supports rich SymPy expressions)
|
|
2
|
+
|
|
3
|
+
This directive builds flexible, textbook‑style mathematical plots using a
|
|
4
|
+
compact YAML‑like front matter plus repeated keys for geometric *primitives*.
|
|
5
|
+
It powers interactive / pedagogical figures while remaining deliberately
|
|
6
|
+
fault‑tolerant: malformed items are skipped instead of aborting the build.
|
|
7
|
+
|
|
8
|
+
NEW / EXTENDED FEATURES (summary)
|
|
9
|
+
---------------------------------
|
|
10
|
+
* Unified expression evaluator: almost every numeric field now accepts SymPy
|
|
11
|
+
expressions (``pi``, ``sqrt(2)``, ``exp(1/3)``, arithmetic, trig, etc.).
|
|
12
|
+
* Function domains & exclusions accept expressions: ``(0, 2*sqrt(5))`` or
|
|
13
|
+
``( -pi, pi ) \ { -pi/2, pi/2 }``.
|
|
14
|
+
* Points, polygons, fill‑polygons, line‑segments, vectors, angle‑arcs,
|
|
15
|
+
circles, ellipses, and parametric curves all evaluate coordinates via SymPy.
|
|
16
|
+
* New primitives: ``circle``, ``ellipse``, ``curve`` (parametric ``x(t), y(t)``).
|
|
17
|
+
* Style / color tokens order‑independent for line‑segment, circle, ellipse,
|
|
18
|
+
curve, angle‑arc (after required numeric parts).
|
|
19
|
+
* Palette mapping: colors first resolved through ``plotmath.COLORS`` then
|
|
20
|
+
fall back to the literal token then a sensible default.
|
|
21
|
+
|
|
22
|
+
Quick start (MyST)
|
|
23
|
+
------------------
|
|
24
|
+
:::{plot}
|
|
25
|
+
function: sin(x)/x, f(x), (-6*pi, 6*pi) \ {0}
|
|
26
|
+
curve: cos(t), sin(2*t), (0, 2*pi), dashed, orange
|
|
27
|
+
circle: (0,0), 2*pi/6, dotted, green
|
|
28
|
+
ellipse: (1, -1), 3, sqrt(5), red
|
|
29
|
+
point: (pi, f(pi))
|
|
30
|
+
line-segment: (0,0), (2*sqrt(2), 2), dashed, purple
|
|
31
|
+
vector: 0, 0, 2*cos(pi/6), 2*sin(pi/6), teal
|
|
32
|
+
angle-arc: (0,0), 2.5, 0, 60, dashed
|
|
33
|
+
annotate: (pi, f(pi)), (pi, 0), "Maximum?", 0.25
|
|
34
|
+
xmin: -10
|
|
35
|
+
xmax: 10
|
|
36
|
+
ymin: -5
|
|
37
|
+
ymax: 5
|
|
38
|
+
grid: on
|
|
39
|
+
ticks: true
|
|
40
|
+
fontsize: 22
|
|
41
|
+
width: 100%
|
|
42
|
+
xlabel: $x$
|
|
43
|
+
ylabel: $y$
|
|
44
|
+
:::
|
|
45
|
+
|
|
46
|
+
MyST *or* classic reST (``.. plot::``) forms are accepted. All examples below
|
|
47
|
+
use MyST for brevity.
|
|
48
|
+
|
|
49
|
+
Front matter
|
|
50
|
+
------------
|
|
51
|
+
* Provide a fenced block introduced by ``:::{plot}`` (preferred) or use an
|
|
52
|
+
unfenced reST directive. Within the block, write ``key: value`` lines.
|
|
53
|
+
* A blank line ends the front matter; remaining lines become the caption.
|
|
54
|
+
* Repeated keys (multi‑valued): ``function``, ``point``, ``annotate``, ``text``,
|
|
55
|
+
``vline``, ``hline``, ``line``, ``line-segment``, ``polygon``, ``fill-polygon``,
|
|
56
|
+
``bar``, ``axis``, ``vector``, ``angle-arc``, ``circle``, ``ellipse``, ``curve``.
|
|
57
|
+
|
|
58
|
+
Global figure & layout options
|
|
59
|
+
------------------------------
|
|
60
|
+
width CSS width (``100%`` or pixels).
|
|
61
|
+
figsize ``(w, h)`` in inches; applied at the end.
|
|
62
|
+
align ``left|center|right``.
|
|
63
|
+
class Extra CSS classes.
|
|
64
|
+
name Stable output filename / anchor.
|
|
65
|
+
alt Alt text (accessibility).
|
|
66
|
+
nocache Force regeneration (ignore cache).
|
|
67
|
+
debug Keep raw SVG size & emit sidecar PDF if possible.
|
|
68
|
+
usetex ``true|false`` force LaTeX text rendering via matplotlib (default ``true``; can be set globally via ``plot_default_usetex`` in ``conf.py``).
|
|
69
|
+
fontsize Base font size (default 20).
|
|
70
|
+
lw Default line width for plotted curves (default 2.5).
|
|
71
|
+
alpha Global alpha for function / curve lines (optional).
|
|
72
|
+
endpoint_markers ``true|false`` draw markers at function domain endpoints (default ``false``). (legacy, use function-endpoints)
|
|
73
|
+
function-endpoints ``true|false`` draw markers at function domain endpoints (default ``false``).
|
|
74
|
+
xmin,xmax,ymin,ymax Axis bounds (defaults ±6).
|
|
75
|
+
xstep,ystep Tick spacing (default 1).
|
|
76
|
+
ticks ``true|false`` master toggle for ticks/labels.
|
|
77
|
+
xticks|yticks ``off`` to remove one axis’s ticks.
|
|
78
|
+
grid ``true|false`` (independent of ``ticks``).
|
|
79
|
+
xlabel,ylabel Axis labels; can add a pad: ``xlabel: $t$, 8``.
|
|
80
|
+
axis Repeated key for special modes: ``off``, ``equal`` (may combine).
|
|
81
|
+
|
|
82
|
+
Expression support
|
|
83
|
+
------------------
|
|
84
|
+
All numeric coordinates (centers, radii, interval endpoints, slopes, etc.) may
|
|
85
|
+
be SymPy expressions. Allowed names include: ``pi``, ``E``, ``sqrt``, ``exp``,
|
|
86
|
+
``log``, ``sin``, ``cos``, ``tan``, ``asin``, ``acos``, ``atan``, ``Abs`` plus
|
|
87
|
+
basic arithmetic. A small, safe namespace is used; arbitrary Python execution
|
|
88
|
+
is not performed. If evaluation fails the item is skipped silently.
|
|
89
|
+
|
|
90
|
+
Functions
|
|
91
|
+
---------
|
|
92
|
+
Forms:
|
|
93
|
+
1. ``function: expr`` — variable is ``x``.
|
|
94
|
+
2. ``function: expr, label``
|
|
95
|
+
3. ``function: expr (a,b)`` — domain restriction (open interval).
|
|
96
|
+
4. ``function: expr [a,b]`` — domain with closed (included) endpoints.
|
|
97
|
+
5. ``function: expr (a,b]`` or ``[a,b)`` — mixed open/closed endpoints.
|
|
98
|
+
6. ``function: expr ⟨a,b⟩`` — domain with angle bracket notation for open endpoints.
|
|
99
|
+
7. ``function: expr (a,b) \ {x1, x2}`` — exclusions inside domain.
|
|
100
|
+
8. List / tuple literal mixing expression, label, domain, exclusion set in
|
|
101
|
+
any order: ``function: [expr, "f(x)", (a,b), {x1, x2}]``.
|
|
102
|
+
|
|
103
|
+
Endpoint markers:
|
|
104
|
+
* Use ``function-endpoints: true`` to enable visual markers at function endpoints.
|
|
105
|
+
* ``[`` / ``]`` denote closed (included) endpoints with bracket markers.
|
|
106
|
+
* ``(`` / ``)`` denote open (excluded) endpoints (default, no marker unless enabled).
|
|
107
|
+
* ``⟨`` / ``⟩`` denote open endpoints with angle bracket markers.
|
|
108
|
+
* Markers are drawn orthogonally to the curve with fixed pixel sizes.
|
|
109
|
+
* Marker size is invariant to axis limits for consistent appearance.
|
|
110
|
+
* Default is ``function-endpoints: false`` (no markers drawn).
|
|
111
|
+
* Legacy keyword: ``endpoint_markers`` (still supported for backward compatibility).
|
|
112
|
+
|
|
113
|
+
Notes:
|
|
114
|
+
* Discontinuities and exclusions are split to avoid vertical strokes.
|
|
115
|
+
* Single‑letter labels are preserved (``g`` no longer coerced into a color).
|
|
116
|
+
* Labels auto‑wrapped for math; don’t include surrounding ``$``.
|
|
117
|
+
|
|
118
|
+
Points
|
|
119
|
+
------
|
|
120
|
+
``point: (x, y)`` where each coordinate can be an expression or a *function
|
|
121
|
+
label call* of the form ``label(number)`` (e.g. ``point: (2, f(2))``).
|
|
122
|
+
|
|
123
|
+
Vertical / horizontal lines
|
|
124
|
+
---------------------------
|
|
125
|
+
``vline: x[, ymin, ymax][, linestyle][, color]``
|
|
126
|
+
``hline: y[, x0, x1][, linestyle][, color]``
|
|
127
|
+
Linestyles: ``solid|dotted|dashed|dashdot`` (order with color is free). Omitted
|
|
128
|
+
range endpoints default to current axis min/max.
|
|
129
|
+
|
|
130
|
+
General line and segments
|
|
131
|
+
-------------------------
|
|
132
|
+
``line: a, (x0, y0)[, linestyle][, color]`` — draws ``y = a*(x - x0) + y0``.
|
|
133
|
+
``line: a, b[, linestyle][, color]`` — draws ``y = a*x + b``.
|
|
134
|
+
``line: (x1, y1), (x2, y2)[, linestyle][, color]`` — draws line through two points.
|
|
135
|
+
``line-segment: (x1, y1), (x2, y2)[, linestyle][, color]`` — finite segment.
|
|
136
|
+
|
|
137
|
+
Polygons
|
|
138
|
+
--------
|
|
139
|
+
``polygon: (x1, y1), (x2, y2), ...[, show_vertices][, color][, alpha]`` — edges with optional fill.
|
|
140
|
+
If color is provided, the polygon interior is filled with the specified color and alpha (default 0.1).
|
|
141
|
+
If no color is provided, only edges are drawn (alpha=0).
|
|
142
|
+
``fill-polygon: (..)[, color][, alpha]`` — filled interior; first non‑numeric
|
|
143
|
+
extra = color, first numeric extra = alpha (default 0.1).
|
|
144
|
+
Coordinates accept expressions and function label calls.
|
|
145
|
+
|
|
146
|
+
Bars
|
|
147
|
+
----
|
|
148
|
+
``bar: (x, y), length, orientation`` where orientation is one of
|
|
149
|
+
``h|hor|horiz|horizontal`` or ``v|vert|vertical``.
|
|
150
|
+
|
|
151
|
+
Vectors
|
|
152
|
+
-------
|
|
153
|
+
``vector`` supports three syntaxes (all numeric fields accept expressions):
|
|
154
|
+
|
|
155
|
+
1. ``vector: x, y, dx, dy[, color]`` — start position and components (legacy format)
|
|
156
|
+
2. ``vector: (x, y), dx, dy[, color]`` — start point as tuple, then components
|
|
157
|
+
3. ``vector: (x1, y1), (x2, y2)[, color]`` — start and end points (components computed automatically)
|
|
158
|
+
|
|
159
|
+
Color mapped through palette then fallback to literal then black.
|
|
160
|
+
|
|
161
|
+
Angle arcs
|
|
162
|
+
----------
|
|
163
|
+
``angle-arc: (cx, cy), radius, start_deg, end_deg[, linestyle][, color]``.
|
|
164
|
+
Angles in *degrees* (mathematical CCW convention). All numeric parts allow
|
|
165
|
+
expressions. Optional style/color order‑independent after the first three
|
|
166
|
+
expressions.
|
|
167
|
+
|
|
168
|
+
Circles & ellipses
|
|
169
|
+
------------------
|
|
170
|
+
Circle: ``circle: (cx, cy), r[, linestyle][, color]`` (r > 0).
|
|
171
|
+
Ellipse: ``ellipse: (cx, cy), a, b[, linestyle][, color]`` (a,b > 0).
|
|
172
|
+
Both sample 1024 points; style/color optional and order‑independent.
|
|
173
|
+
|
|
174
|
+
Parametric curves
|
|
175
|
+
-----------------
|
|
176
|
+
``curve: x_expr, y_expr, (t0, t1)[, linestyle][, color]`` — samples 1024 points
|
|
177
|
+
with ``t`` symbol. Interval endpoints may be expressions (auto‑swapped if
|
|
178
|
+
reversed). Style/color optional.
|
|
179
|
+
|
|
180
|
+
Annotations & text
|
|
181
|
+
------------------
|
|
182
|
+
``annotate: (xytext), (xy), "text"[, arc]`` — arrow annotation; coordinates &
|
|
183
|
+
arc curvature can be expressions.
|
|
184
|
+
``text: [x, y, string[, pos][, bbox]]`` — position tokens: ``top-left``,
|
|
185
|
+
``center-center``, etc.; *long* variants shift further (e.g. ``longtop-left``).
|
|
186
|
+
|
|
187
|
+
Axis overrides: off / equal
|
|
188
|
+
---------------------------
|
|
189
|
+
``axis: off`` hides frame & ticks (manual artists still drawn).
|
|
190
|
+
``axis: equal`` enforces 1:1 aspect (may combine with ``off``). Additional
|
|
191
|
+
``axis: tight`` etc. still applied in visible‑axis mode.
|
|
192
|
+
|
|
193
|
+
Color & linestyle resolution
|
|
194
|
+
----------------------------
|
|
195
|
+
1. Try ``plotmath.COLORS[name]``.
|
|
196
|
+
2. Fallback to the literal (Matplotlib named or hex) token.
|
|
197
|
+
3. Fallback default (e.g. black, red, blue depending on primitive).
|
|
198
|
+
Single‑letter Matplotlib shorthands are *disabled for function labels* to
|
|
199
|
+
avoid ambiguity.
|
|
200
|
+
|
|
201
|
+
Safety & robustness
|
|
202
|
+
-------------------
|
|
203
|
+
* Expression evaluation uses SymPy in a restricted namespace (no exec / eval).
|
|
204
|
+
* Any parsing failure for an individual primitive silently skips that item.
|
|
205
|
+
* Cached SVGs stored under ``_static/plot/`` keyed by a content hash unless a
|
|
206
|
+
``name:`` override is supplied.
|
|
207
|
+
|
|
208
|
+
Caption
|
|
209
|
+
-------
|
|
210
|
+
Lines after a blank line (or after the front matter fence) become the caption.
|
|
211
|
+
|
|
212
|
+
This docstring is intentionally exhaustive; HOWTOWRITE.md contains user‑facing
|
|
213
|
+
Norwegian examples.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
from __future__ import annotations
|
|
217
|
+
|
|
218
|
+
import ast
|
|
219
|
+
import hashlib
|
|
220
|
+
import os
|
|
221
|
+
import re
|
|
222
|
+
import shutil
|
|
223
|
+
import uuid
|
|
224
|
+
from typing import Any, Callable, Dict, List, Tuple
|
|
225
|
+
|
|
226
|
+
from docutils import nodes
|
|
227
|
+
from docutils.parsers.rst import directives
|
|
228
|
+
from sphinx.util.docutils import SphinxDirective
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ------------------------------------
|
|
232
|
+
# Utilities
|
|
233
|
+
# ------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _hash_key(*parts) -> str:
|
|
237
|
+
h = hashlib.sha1()
|
|
238
|
+
for p in parts:
|
|
239
|
+
if p is None:
|
|
240
|
+
p = "__NONE__"
|
|
241
|
+
h.update(str(p).encode("utf-8"))
|
|
242
|
+
h.update(b"||")
|
|
243
|
+
return h.hexdigest()[:12]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _compile_function(expr: str) -> Callable:
|
|
247
|
+
import sympy, numpy as np
|
|
248
|
+
from scipy import special as sp_special
|
|
249
|
+
|
|
250
|
+
expr = expr.strip()
|
|
251
|
+
x = sympy.symbols("x")
|
|
252
|
+
try:
|
|
253
|
+
sym = sympy.sympify(expr)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
raise ValueError(f"Ugyldig funksjonsuttrykk '{expr}': {e}")
|
|
256
|
+
# If the expression does not depend on x, treat it as a constant function
|
|
257
|
+
if not sym.free_symbols or sym.free_symbols.isdisjoint({x}):
|
|
258
|
+
const_val = float(sym.evalf())
|
|
259
|
+
|
|
260
|
+
def f(arr):
|
|
261
|
+
a = np.asarray(arr, dtype=float)
|
|
262
|
+
return np.full_like(a, fill_value=const_val, dtype=float)
|
|
263
|
+
|
|
264
|
+
_ = f([0.0, 1.0])
|
|
265
|
+
return f
|
|
266
|
+
|
|
267
|
+
# Include scipy.special for erf and other special functions
|
|
268
|
+
fn_np = sympy.lambdify(x, sym, modules=[{"erf": sp_special.erf}, "numpy"])
|
|
269
|
+
|
|
270
|
+
def f(arr):
|
|
271
|
+
return fn_np(np.asarray(arr, dtype=float))
|
|
272
|
+
|
|
273
|
+
_ = f([0.0, 1.0])
|
|
274
|
+
return f
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _parse_bool(val, default: bool | None = None) -> bool | None:
|
|
278
|
+
if val is None:
|
|
279
|
+
return default
|
|
280
|
+
if isinstance(val, bool):
|
|
281
|
+
return val
|
|
282
|
+
s = str(val).strip().lower()
|
|
283
|
+
if s == "":
|
|
284
|
+
return True
|
|
285
|
+
if s in {"true", "yes", "on", "1"}:
|
|
286
|
+
return True
|
|
287
|
+
if s in {"false", "no", "off", "0"}:
|
|
288
|
+
return False
|
|
289
|
+
return default
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _strip_root_svg_size(svg_text: str) -> str:
|
|
293
|
+
def repl(m):
|
|
294
|
+
tag = m.group(0)
|
|
295
|
+
tag = re.sub(r'\swidth="[^"]+"', "", tag)
|
|
296
|
+
tag = re.sub(r'\sheight="[^"]+"', "", tag)
|
|
297
|
+
return tag
|
|
298
|
+
|
|
299
|
+
return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _rewrite_ids(txt: str, prefix: str) -> str:
|
|
303
|
+
ids = re.findall(r'\bid="([^"]+)"', txt)
|
|
304
|
+
if not ids:
|
|
305
|
+
return txt
|
|
306
|
+
skip_prefixes = (
|
|
307
|
+
"DejaVu",
|
|
308
|
+
"CM",
|
|
309
|
+
"STIX",
|
|
310
|
+
"Nimbus",
|
|
311
|
+
"Bitstream",
|
|
312
|
+
"Arial",
|
|
313
|
+
"Times",
|
|
314
|
+
"Helvetica",
|
|
315
|
+
)
|
|
316
|
+
mapping = {}
|
|
317
|
+
for i in ids:
|
|
318
|
+
if i.startswith(skip_prefixes):
|
|
319
|
+
continue
|
|
320
|
+
mapping[i] = f"{prefix}{i}"
|
|
321
|
+
if not mapping:
|
|
322
|
+
return txt
|
|
323
|
+
|
|
324
|
+
def repl_id(m: re.Match) -> str:
|
|
325
|
+
old = m.group(1)
|
|
326
|
+
new = mapping.get(old, old)
|
|
327
|
+
return f'id="{new}"'
|
|
328
|
+
|
|
329
|
+
txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
|
|
330
|
+
|
|
331
|
+
def repl_url(m: re.Match) -> str:
|
|
332
|
+
old = m.group(1).strip()
|
|
333
|
+
new = mapping.get(old, old)
|
|
334
|
+
return f"url(#{new})"
|
|
335
|
+
|
|
336
|
+
txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
|
|
337
|
+
|
|
338
|
+
def repl_href(m: re.Match) -> str:
|
|
339
|
+
attr = m.group(1)
|
|
340
|
+
quote = m.group(2)
|
|
341
|
+
old = m.group(3).strip()
|
|
342
|
+
new = mapping.get(old, old)
|
|
343
|
+
return f"{attr}={quote}#{new}{quote}"
|
|
344
|
+
|
|
345
|
+
txt = re.sub(r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2', repl_href, txt)
|
|
346
|
+
return txt
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _safe_literal(val: str):
|
|
350
|
+
try:
|
|
351
|
+
return ast.literal_eval(val)
|
|
352
|
+
except Exception:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _split_list(val: str) -> List[str]:
|
|
357
|
+
s = str(val or "").strip()
|
|
358
|
+
if not s:
|
|
359
|
+
return []
|
|
360
|
+
if s.startswith("[") and s.endswith("]"):
|
|
361
|
+
s = s[1:-1]
|
|
362
|
+
s = s.replace(";", ",")
|
|
363
|
+
return [p.strip() for p in s.split(",") if p.strip()]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _parse_text_positioning(pos: str) -> Tuple[str, str]:
|
|
367
|
+
"""Map positioning string to (va, ha). Default is (top, left).
|
|
368
|
+
|
|
369
|
+
Accepted values (case-insensitive, hyphen or underscore allowed):
|
|
370
|
+
- top-left, top-right, bottom-left, bottom-right,
|
|
371
|
+
- top-center, bottom-center, center-left, center-right
|
|
372
|
+
"""
|
|
373
|
+
if not isinstance(pos, str):
|
|
374
|
+
return ("top", "left")
|
|
375
|
+
key = pos.strip().lower().replace("_", "-")
|
|
376
|
+
|
|
377
|
+
# Map onto the opposites to make intuitive sense.
|
|
378
|
+
# Matplotlib expects the position to refer to where the object is relative to the text.
|
|
379
|
+
# Thus "left" means the object is to the "left" of the text.
|
|
380
|
+
# Here "left" will mean "move the text to the left of the object"
|
|
381
|
+
mapping = {
|
|
382
|
+
"top-left": ("bottom", "right"),
|
|
383
|
+
"top-right": ("bottom", "left"),
|
|
384
|
+
"bottom-left": ("top", "right"),
|
|
385
|
+
"bottom-right": ("top", "left"),
|
|
386
|
+
"top-center": ("bottom", "center"),
|
|
387
|
+
"bottom-center": ("top", "center"),
|
|
388
|
+
"center-left": ("center", "right"),
|
|
389
|
+
"center-right": ("center", "left"),
|
|
390
|
+
# Longer distance from point
|
|
391
|
+
"longtop-left": ("longbottom", "left"),
|
|
392
|
+
"longtop-longleft": ("longbottom", "longright"),
|
|
393
|
+
"longbottom-right": ("longtop", "right"),
|
|
394
|
+
"longbottom-left": ("longtop", "left"),
|
|
395
|
+
"longtop-center": ("longbottom", "center"),
|
|
396
|
+
"longbottom-center": ("longtop", "center"),
|
|
397
|
+
"longtop-longright": ("longbottom", "longleft"),
|
|
398
|
+
"longbottom-longright": ("longtop", "longleft"),
|
|
399
|
+
"longtop-longleft": ("longbottom", "longright"),
|
|
400
|
+
"longbottom-longleft": ("longtop", "longright"),
|
|
401
|
+
"top-longleft": ("bottom", "longright"),
|
|
402
|
+
"top-longright": ("bottom", "longleft"),
|
|
403
|
+
"bottom-longleft": ("top", "longright"),
|
|
404
|
+
"bottom-longright": ("top", "longleft"),
|
|
405
|
+
"center-longleft": ("center", "longright"),
|
|
406
|
+
"center-longright": ("center", "longleft"),
|
|
407
|
+
"center-center": ("center", "center"),
|
|
408
|
+
}
|
|
409
|
+
return mapping.get(key, ("top", "left"))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class PlotDirective(SphinxDirective):
|
|
413
|
+
has_content = True
|
|
414
|
+
required_arguments = 0
|
|
415
|
+
option_spec = {
|
|
416
|
+
# presentation / misc
|
|
417
|
+
"width": directives.length_or_percentage_or_unitless,
|
|
418
|
+
"align": lambda a: directives.choice(a, ["left", "center", "right"]),
|
|
419
|
+
"class": directives.class_option,
|
|
420
|
+
"name": directives.unchanged,
|
|
421
|
+
"nocache": directives.flag,
|
|
422
|
+
"debug": directives.flag,
|
|
423
|
+
"alt": directives.unchanged,
|
|
424
|
+
# text rendering
|
|
425
|
+
"usetex": directives.unchanged,
|
|
426
|
+
# axes options (optional in YAML too)
|
|
427
|
+
"xmin": directives.unchanged,
|
|
428
|
+
"xmax": directives.unchanged,
|
|
429
|
+
"ymin": directives.unchanged,
|
|
430
|
+
"ymax": directives.unchanged,
|
|
431
|
+
"xstep": directives.unchanged,
|
|
432
|
+
"ystep": directives.unchanged,
|
|
433
|
+
"fontsize": directives.unchanged,
|
|
434
|
+
"ticks": directives.unchanged,
|
|
435
|
+
"grid": directives.unchanged,
|
|
436
|
+
"xticks": directives.unchanged,
|
|
437
|
+
"yticks": directives.unchanged,
|
|
438
|
+
"lw": directives.unchanged,
|
|
439
|
+
"alpha": directives.unchanged,
|
|
440
|
+
"figsize": directives.unchanged,
|
|
441
|
+
"endpoint_markers": directives.unchanged, # legacy, use function-endpoints
|
|
442
|
+
"function-endpoints": directives.unchanged,
|
|
443
|
+
# axis labels
|
|
444
|
+
"xlabel": directives.unchanged,
|
|
445
|
+
"ylabel": directives.unchanged,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
def _parse_kv_block(self) -> Tuple[Dict[str, Any], Dict[str, List[str]], int]:
|
|
449
|
+
"""Parse front matter supporting repeated keys for function/point/annotate.
|
|
450
|
+
|
|
451
|
+
Returns: (scalars, lists, caption_idx)
|
|
452
|
+
"""
|
|
453
|
+
lines = list(self.content)
|
|
454
|
+
scalars: Dict[str, Any] = {}
|
|
455
|
+
lists: Dict[str, List[str]] = {
|
|
456
|
+
"function": [],
|
|
457
|
+
"point": [],
|
|
458
|
+
"annotate": [],
|
|
459
|
+
"text": [],
|
|
460
|
+
"vline": [],
|
|
461
|
+
"hline": [],
|
|
462
|
+
"line": [],
|
|
463
|
+
"tangent": [],
|
|
464
|
+
"polygon": [],
|
|
465
|
+
"axis": [],
|
|
466
|
+
"fill-polygon": [],
|
|
467
|
+
"bar": [],
|
|
468
|
+
"vector": [],
|
|
469
|
+
"line-segment": [],
|
|
470
|
+
"angle-arc": [],
|
|
471
|
+
"circle": [],
|
|
472
|
+
"ellipse": [],
|
|
473
|
+
"curve": [],
|
|
474
|
+
}
|
|
475
|
+
# YAML-like fenced front matter
|
|
476
|
+
if lines and lines[0].strip() == "---":
|
|
477
|
+
idx = 1
|
|
478
|
+
while idx < len(lines) and lines[idx].strip() != "---":
|
|
479
|
+
line = lines[idx].rstrip()
|
|
480
|
+
if not line.strip():
|
|
481
|
+
idx += 1
|
|
482
|
+
continue
|
|
483
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
484
|
+
if m:
|
|
485
|
+
key, value = m.group(1), m.group(2)
|
|
486
|
+
if key in lists:
|
|
487
|
+
lists[key].append(value)
|
|
488
|
+
else:
|
|
489
|
+
scalars[key] = value
|
|
490
|
+
idx += 1
|
|
491
|
+
# Skip closing fence if present
|
|
492
|
+
if idx < len(lines) and lines[idx].strip() == "---":
|
|
493
|
+
idx += 1
|
|
494
|
+
# Skip trailing blanks before caption
|
|
495
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
496
|
+
idx += 1
|
|
497
|
+
return scalars, lists, idx
|
|
498
|
+
|
|
499
|
+
# Fallback: non-fenced lines until first non "key: value" or a blank separator
|
|
500
|
+
caption_start = 0
|
|
501
|
+
for i, line in enumerate(lines):
|
|
502
|
+
if not line.strip():
|
|
503
|
+
caption_start = i + 1
|
|
504
|
+
continue
|
|
505
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
506
|
+
if m:
|
|
507
|
+
key, value = m.group(1), m.group(2)
|
|
508
|
+
if key in lists:
|
|
509
|
+
lists[key].append(value)
|
|
510
|
+
else:
|
|
511
|
+
scalars[key] = value
|
|
512
|
+
caption_start = i + 1
|
|
513
|
+
else:
|
|
514
|
+
break
|
|
515
|
+
return scalars, lists, caption_start
|
|
516
|
+
|
|
517
|
+
def run(self):
|
|
518
|
+
env = self.state.document.settings.env
|
|
519
|
+
app = env.app
|
|
520
|
+
try:
|
|
521
|
+
import plotmath # type: ignore
|
|
522
|
+
import numpy as np # used for x sampling when plotting functions
|
|
523
|
+
except Exception:
|
|
524
|
+
# Fall back to bundled development shim so tests and docs can build without external plotmath
|
|
525
|
+
try:
|
|
526
|
+
from munchboka_edutools import _plotmath_shim as plotmath # type: ignore
|
|
527
|
+
import numpy as np # used for x sampling when plotting functions
|
|
528
|
+
except Exception as e:
|
|
529
|
+
err = nodes.error()
|
|
530
|
+
err += nodes.paragraph(text=f"Kunne ikke importere plotmath: {e}")
|
|
531
|
+
return [err]
|
|
532
|
+
|
|
533
|
+
scalars, lists, caption_idx = self._parse_kv_block()
|
|
534
|
+
merged: Dict[str, Any] = {**scalars, **self.options}
|
|
535
|
+
|
|
536
|
+
# debug print removed
|
|
537
|
+
|
|
538
|
+
def _f(name, default):
|
|
539
|
+
v = merged.get(name)
|
|
540
|
+
if v in (None, ""):
|
|
541
|
+
return default
|
|
542
|
+
try:
|
|
543
|
+
return float(v)
|
|
544
|
+
except Exception:
|
|
545
|
+
return default
|
|
546
|
+
|
|
547
|
+
xmin = _f("xmin", -6)
|
|
548
|
+
xmax = _f("xmax", 6)
|
|
549
|
+
ymin = _f("ymin", -6)
|
|
550
|
+
ymax = _f("ymax", 6)
|
|
551
|
+
xstep = _f("xstep", 1)
|
|
552
|
+
ystep = _f("ystep", 1)
|
|
553
|
+
fontsize = _f("fontsize", 20)
|
|
554
|
+
lw = _f("lw", 2.5)
|
|
555
|
+
alpha_raw = merged.get("alpha")
|
|
556
|
+
figsize_raw = merged.get("figsize")
|
|
557
|
+
try:
|
|
558
|
+
alpha = float(alpha_raw) if alpha_raw not in (None, "") else None
|
|
559
|
+
except Exception:
|
|
560
|
+
alpha = None
|
|
561
|
+
|
|
562
|
+
ticks_flag = _parse_bool(merged.get("ticks"), default=None)
|
|
563
|
+
grid_flag = _parse_bool(merged.get("grid"), default=None)
|
|
564
|
+
# Check function-endpoints first, fall back to endpoint_markers for legacy support
|
|
565
|
+
endpoint_markers_flag = _parse_bool(
|
|
566
|
+
merged.get("function-endpoints") or merged.get("endpoint_markers"), default=False
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Set defaults: if neither is specified, both default to True
|
|
570
|
+
if ticks_flag is None and grid_flag is None:
|
|
571
|
+
ticks_flag = True
|
|
572
|
+
grid_flag = True
|
|
573
|
+
else:
|
|
574
|
+
# If ticks not explicitly set, default to True (independent of grid setting)
|
|
575
|
+
if ticks_flag is None:
|
|
576
|
+
ticks_flag = True
|
|
577
|
+
else:
|
|
578
|
+
ticks_flag = bool(ticks_flag)
|
|
579
|
+
|
|
580
|
+
# If grid not explicitly set, default to True (independent of ticks setting)
|
|
581
|
+
if grid_flag is None:
|
|
582
|
+
grid_flag = True
|
|
583
|
+
else:
|
|
584
|
+
grid_flag = bool(grid_flag)
|
|
585
|
+
|
|
586
|
+
# Compile functions (may be zero or many) and parse optional labels, optional domain (xmin,xmax), exclusions and color
|
|
587
|
+
raw_fn_items = lists.get("function", [])
|
|
588
|
+
fn_exprs: List[str] = []
|
|
589
|
+
fn_labels_list: List[str] = []
|
|
590
|
+
fn_domains_list: List[Tuple[float, float] | None] = []
|
|
591
|
+
fn_exclusions_list: List[List[float]] = []
|
|
592
|
+
fn_colors_list: List[str] = []
|
|
593
|
+
fn_endpoints_list: List[Tuple[str, str]] = (
|
|
594
|
+
[]
|
|
595
|
+
) # (left_type, right_type): "open", "closed", or "none"
|
|
596
|
+
functions: List[Callable] = []
|
|
597
|
+
|
|
598
|
+
def _parse_function_item(
|
|
599
|
+
s: str,
|
|
600
|
+
) -> Tuple[
|
|
601
|
+
str,
|
|
602
|
+
str | None,
|
|
603
|
+
Tuple[float, float] | None,
|
|
604
|
+
List[float],
|
|
605
|
+
str | None,
|
|
606
|
+
Tuple[str, str], # (left_endpoint_type, right_endpoint_type)
|
|
607
|
+
]:
|
|
608
|
+
s = str(s).strip()
|
|
609
|
+
|
|
610
|
+
# Heuristic to detect if a string token looks like a color
|
|
611
|
+
def _looks_like_color(tok: str) -> bool:
|
|
612
|
+
if not isinstance(tok, str):
|
|
613
|
+
return False
|
|
614
|
+
t = tok.strip()
|
|
615
|
+
if not t:
|
|
616
|
+
return False
|
|
617
|
+
# hex colors
|
|
618
|
+
if re.match(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", t):
|
|
619
|
+
return True
|
|
620
|
+
# IMPORTANT: single-letter matplotlib shorthands intentionally
|
|
621
|
+
# NOT treated as colors here so that users can name functions
|
|
622
|
+
# with letters like 'g' without it being consumed as green.
|
|
623
|
+
# Use full names (e.g. 'green') or color=green instead.
|
|
624
|
+
if t.lower().startswith("tab:"):
|
|
625
|
+
return True
|
|
626
|
+
if re.match(r"^C\d+$", t):
|
|
627
|
+
return True
|
|
628
|
+
# plotmath named colors
|
|
629
|
+
try:
|
|
630
|
+
import plotmath as _pm
|
|
631
|
+
|
|
632
|
+
if _pm.COLORS.get(t) is not None:
|
|
633
|
+
return True
|
|
634
|
+
except Exception:
|
|
635
|
+
pass
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
# Try literal list/tuple like ("expr", "label") or ("expr", (xmin,xmax)) or ("expr", "label", (xmin,xmax)) in any order
|
|
639
|
+
lit = _safe_literal(s)
|
|
640
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 1:
|
|
641
|
+
expr = str(lit[0]).strip()
|
|
642
|
+
label: str | None = None
|
|
643
|
+
domain: Tuple[float, float] | None = None
|
|
644
|
+
excludes: List[float] = []
|
|
645
|
+
color: str | None = None
|
|
646
|
+
for item in list(lit[1:]):
|
|
647
|
+
# Detect domain tuple/list of two numbers
|
|
648
|
+
if domain is None and isinstance(item, (list, tuple)) and len(item) == 2:
|
|
649
|
+
try:
|
|
650
|
+
d0 = float(item[0])
|
|
651
|
+
d1 = float(item[1])
|
|
652
|
+
domain = (d0, d1)
|
|
653
|
+
continue
|
|
654
|
+
except Exception:
|
|
655
|
+
pass
|
|
656
|
+
# Detect exclusions as a collection of numbers (not a 2-tuple domain)
|
|
657
|
+
if isinstance(item, (set, list, tuple)) and not (
|
|
658
|
+
isinstance(item, (list, tuple)) and len(item) == 2
|
|
659
|
+
):
|
|
660
|
+
for v in item:
|
|
661
|
+
try:
|
|
662
|
+
excludes.append(float(v))
|
|
663
|
+
except Exception:
|
|
664
|
+
pass
|
|
665
|
+
# Else string-like: support label/color, order-independent
|
|
666
|
+
if isinstance(item, str):
|
|
667
|
+
tok = item.strip()
|
|
668
|
+
if tok.lower().startswith("label="):
|
|
669
|
+
if label is None:
|
|
670
|
+
label = tok.split("=", 1)[1].strip()
|
|
671
|
+
continue
|
|
672
|
+
if tok.lower().startswith("color="):
|
|
673
|
+
if color is None:
|
|
674
|
+
color = tok.split("=", 1)[1].strip()
|
|
675
|
+
continue
|
|
676
|
+
if color is None and _looks_like_color(tok):
|
|
677
|
+
color = tok
|
|
678
|
+
elif label is None:
|
|
679
|
+
lab = tok
|
|
680
|
+
label = lab if lab else None
|
|
681
|
+
return expr, label, domain, excludes, color, ("none", "none")
|
|
682
|
+
# Fallback: attempt to locate a domain pattern with balanced brackets
|
|
683
|
+
# and optional exclusions of the form \{a,b,c}. Supports nested parentheses in each endpoint
|
|
684
|
+
# (e.g. (0, 2*sqrt(5)) or (pi/4, 3*pi/2 + sqrt(2))).
|
|
685
|
+
domain: Tuple[float, float] | None = None
|
|
686
|
+
excludes: List[float] = []
|
|
687
|
+
color: str | None = None
|
|
688
|
+
|
|
689
|
+
def _sym_eval_num(txt: str) -> float:
|
|
690
|
+
import sympy
|
|
691
|
+
|
|
692
|
+
allowed = {
|
|
693
|
+
k: getattr(sympy, k)
|
|
694
|
+
for k in [
|
|
695
|
+
"pi",
|
|
696
|
+
"E",
|
|
697
|
+
"exp",
|
|
698
|
+
"sqrt",
|
|
699
|
+
"log",
|
|
700
|
+
"sin",
|
|
701
|
+
"cos",
|
|
702
|
+
"tan",
|
|
703
|
+
"asin",
|
|
704
|
+
"acos",
|
|
705
|
+
"atan",
|
|
706
|
+
"Rational",
|
|
707
|
+
]
|
|
708
|
+
if hasattr(sympy, k)
|
|
709
|
+
}
|
|
710
|
+
expr = sympy.sympify(txt, locals=allowed)
|
|
711
|
+
return float(expr.evalf())
|
|
712
|
+
|
|
713
|
+
def _extract_domain_and_exclusions(text: str):
|
|
714
|
+
"""Return (domain_tuple|None, exclusions_list, text_without_domain, left_bracket, right_bracket).
|
|
715
|
+
Heuristic: find the first bracket block whose top-level content
|
|
716
|
+
splits into exactly two parts by a single top-level comma.
|
|
717
|
+
Supports: ( ), [ ], ⟨ ⟩ (angle brackets for open)
|
|
718
|
+
"""
|
|
719
|
+
n = len(text)
|
|
720
|
+
i = 0
|
|
721
|
+
while i < n:
|
|
722
|
+
left_bracket = None
|
|
723
|
+
right_bracket = None
|
|
724
|
+
if text[i] in "([⟨":
|
|
725
|
+
left_bracket = text[i]
|
|
726
|
+
# Accept any closing bracket to support mixed brackets like [-1, 3) or (-1, 3]
|
|
727
|
+
depth = 1
|
|
728
|
+
j = i + 1
|
|
729
|
+
while j < n and depth > 0:
|
|
730
|
+
ch = text[j]
|
|
731
|
+
if ch in "([⟨":
|
|
732
|
+
depth += 1
|
|
733
|
+
elif ch in ")]⟩":
|
|
734
|
+
depth -= 1
|
|
735
|
+
if depth == 0:
|
|
736
|
+
right_bracket = ch
|
|
737
|
+
j += 1
|
|
738
|
+
if depth != 0:
|
|
739
|
+
# unbalanced, give up
|
|
740
|
+
break
|
|
741
|
+
content = text[i + 1 : j - 1].strip()
|
|
742
|
+
# Find a single top-level comma in content
|
|
743
|
+
depth2 = 0
|
|
744
|
+
comma_index: int | None = None
|
|
745
|
+
for k, ch in enumerate(content):
|
|
746
|
+
if ch == "(":
|
|
747
|
+
depth2 += 1
|
|
748
|
+
elif ch == ")":
|
|
749
|
+
depth2 -= 1
|
|
750
|
+
elif ch == "," and depth2 == 0:
|
|
751
|
+
if comma_index is None:
|
|
752
|
+
comma_index = k
|
|
753
|
+
else:
|
|
754
|
+
# more than one top-level comma => not domain
|
|
755
|
+
comma_index = None
|
|
756
|
+
break
|
|
757
|
+
if comma_index is not None:
|
|
758
|
+
left = content[:comma_index].strip()
|
|
759
|
+
right = content[comma_index + 1 :].strip()
|
|
760
|
+
if left and right:
|
|
761
|
+
# Optional exclusions right after ) like \{a,b}
|
|
762
|
+
k2 = j
|
|
763
|
+
excl_list: List[float] = []
|
|
764
|
+
# skip whitespace
|
|
765
|
+
while k2 < n and text[k2].isspace():
|
|
766
|
+
k2 += 1
|
|
767
|
+
if k2 < n and text[k2] == "\\":
|
|
768
|
+
k2 += 1
|
|
769
|
+
while k2 < n and text[k2].isspace():
|
|
770
|
+
k2 += 1
|
|
771
|
+
if k2 < n and text[k2] == "{":
|
|
772
|
+
k2 += 1
|
|
773
|
+
excl_start = k2
|
|
774
|
+
# read until matching '}' (no nesting expected here)
|
|
775
|
+
while k2 < n and text[k2] != "}":
|
|
776
|
+
k2 += 1
|
|
777
|
+
excl_content = text[excl_start:k2]
|
|
778
|
+
if k2 < n and text[k2] == "}":
|
|
779
|
+
k2 += 1 # consume '}'
|
|
780
|
+
for tok in [
|
|
781
|
+
t.strip() for t in excl_content.split(",") if t.strip()
|
|
782
|
+
]:
|
|
783
|
+
try:
|
|
784
|
+
excl_list.append(_sym_eval_num(tok))
|
|
785
|
+
except Exception:
|
|
786
|
+
pass
|
|
787
|
+
# Attempt numeric evaluation of endpoints
|
|
788
|
+
dom_tuple: Tuple[float, float] | None = None
|
|
789
|
+
try:
|
|
790
|
+
d0 = _sym_eval_num(left)
|
|
791
|
+
d1 = _sym_eval_num(right)
|
|
792
|
+
dom_tuple = (d0, d1)
|
|
793
|
+
except Exception:
|
|
794
|
+
dom_tuple = None
|
|
795
|
+
# Remove the consumed substring from original text
|
|
796
|
+
new_text = (text[:i] + text[k2:]).strip()
|
|
797
|
+
# Determine endpoint types from bracket types
|
|
798
|
+
left_type = "closed" if left_bracket == "[" else "open"
|
|
799
|
+
right_type = "closed" if right_bracket == "]" else "open"
|
|
800
|
+
return dom_tuple, excl_list, new_text, left_type, right_type
|
|
801
|
+
# Move past this bracket group and continue scanning
|
|
802
|
+
i = j
|
|
803
|
+
continue
|
|
804
|
+
i += 1
|
|
805
|
+
return None, [], text, "none", "none"
|
|
806
|
+
|
|
807
|
+
domain, excludes, s_wo_dom, left_endpoint, right_endpoint = (
|
|
808
|
+
_extract_domain_and_exclusions(s)
|
|
809
|
+
)
|
|
810
|
+
# Tokenize on commas to robustly drop empty segments created by domain removal
|
|
811
|
+
parts = [p.strip() for p in s_wo_dom.split(",") if p.strip()]
|
|
812
|
+
if parts:
|
|
813
|
+
expr = parts[0]
|
|
814
|
+
label = None
|
|
815
|
+
# Scan remaining tokens for label/color using heuristics
|
|
816
|
+
for tok in parts[1:]:
|
|
817
|
+
t = tok.strip()
|
|
818
|
+
if t.lower().startswith("label="):
|
|
819
|
+
if label is None:
|
|
820
|
+
label = t.split("=", 1)[1].strip()
|
|
821
|
+
continue
|
|
822
|
+
if t.lower().startswith("color="):
|
|
823
|
+
if color is None:
|
|
824
|
+
color = t.split("=", 1)[1].strip()
|
|
825
|
+
continue
|
|
826
|
+
if color is None and _looks_like_color(t):
|
|
827
|
+
color = t
|
|
828
|
+
elif label is None:
|
|
829
|
+
label = t
|
|
830
|
+
return expr, label, domain, excludes, color, (left_endpoint, right_endpoint)
|
|
831
|
+
# Only expression provided (or empty after cleanup)
|
|
832
|
+
return s_wo_dom.strip(), None, domain, excludes, None, (left_endpoint, right_endpoint)
|
|
833
|
+
|
|
834
|
+
for item in raw_fn_items:
|
|
835
|
+
expr, label, domain, excludes, color, endpoints = _parse_function_item(item)
|
|
836
|
+
try:
|
|
837
|
+
functions.append(_compile_function(expr))
|
|
838
|
+
fn_exprs.append(expr)
|
|
839
|
+
fn_labels_list.append(label or "")
|
|
840
|
+
fn_domains_list.append(domain)
|
|
841
|
+
fn_exclusions_list.append(sorted(excludes))
|
|
842
|
+
fn_colors_list.append(color or "")
|
|
843
|
+
fn_endpoints_list.append(endpoints)
|
|
844
|
+
except Exception as ex:
|
|
845
|
+
return [
|
|
846
|
+
self.state_machine.reporter.error(
|
|
847
|
+
f"Ugyldig funksjon '{expr}': {ex}", line=self.lineno
|
|
848
|
+
)
|
|
849
|
+
]
|
|
850
|
+
|
|
851
|
+
# ------------------------------------
|
|
852
|
+
# Unified numeric expression evaluator
|
|
853
|
+
# Supports:
|
|
854
|
+
# * Plain numbers
|
|
855
|
+
# * Arithmetic & SymPy functions (sqrt, pi, sin, ...)
|
|
856
|
+
# * References to previously defined function labels: f(2), g(pi/4+1)
|
|
857
|
+
# Limitations: nested user function calls deeper than 50 rewrites are blocked.
|
|
858
|
+
# ------------------------------------
|
|
859
|
+
_num_cache: Dict[str, float] = {}
|
|
860
|
+
|
|
861
|
+
def _eval_expr(val) -> float:
|
|
862
|
+
import sympy, re
|
|
863
|
+
|
|
864
|
+
if val is None:
|
|
865
|
+
raise ValueError("Empty value")
|
|
866
|
+
if isinstance(val, (int, float)):
|
|
867
|
+
return float(val)
|
|
868
|
+
s0 = str(val).strip()
|
|
869
|
+
if not s0:
|
|
870
|
+
raise ValueError("Blank numeric expression")
|
|
871
|
+
if s0 in _num_cache:
|
|
872
|
+
return _num_cache[s0]
|
|
873
|
+
s = s0
|
|
874
|
+
# Replace user function label calls iteratively, allowing
|
|
875
|
+
# general expressions as arguments, e.g. f(2 - sqrt(2)).
|
|
876
|
+
for _ in range(50):
|
|
877
|
+
m = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\(", s)
|
|
878
|
+
if not m:
|
|
879
|
+
break
|
|
880
|
+
lbl = m.group(1)
|
|
881
|
+
if lbl not in fn_labels_list:
|
|
882
|
+
# Skip this label, look for the next one
|
|
883
|
+
start_next = m.start() + 1
|
|
884
|
+
n = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\(", s[start_next:])
|
|
885
|
+
if not n:
|
|
886
|
+
break
|
|
887
|
+
m = n
|
|
888
|
+
lbl = m.group(1)
|
|
889
|
+
if lbl not in fn_labels_list:
|
|
890
|
+
break
|
|
891
|
+
# Find matching closing parenthesis for this call
|
|
892
|
+
start = m.end() - 1 # position of '('
|
|
893
|
+
depth = 0
|
|
894
|
+
end = None
|
|
895
|
+
for i in range(start, len(s)):
|
|
896
|
+
if s[i] == "(":
|
|
897
|
+
depth += 1
|
|
898
|
+
elif s[i] == ")":
|
|
899
|
+
depth -= 1
|
|
900
|
+
if depth == 0:
|
|
901
|
+
end = i
|
|
902
|
+
break
|
|
903
|
+
if end is None:
|
|
904
|
+
break
|
|
905
|
+
arg_expr = s[start + 1 : end]
|
|
906
|
+
try:
|
|
907
|
+
arg_val = _eval_expr(arg_expr)
|
|
908
|
+
idx = fn_labels_list.index(lbl)
|
|
909
|
+
f = functions[idx]
|
|
910
|
+
yv = float(f([arg_val])[0])
|
|
911
|
+
s = s[: m.start()] + f"{yv}" + s[end + 1 :]
|
|
912
|
+
continue
|
|
913
|
+
except Exception:
|
|
914
|
+
# leave unresolved for sympy if evaluation fails
|
|
915
|
+
break
|
|
916
|
+
allowed = {
|
|
917
|
+
k: getattr(sympy, k)
|
|
918
|
+
for k in [
|
|
919
|
+
"pi",
|
|
920
|
+
"E",
|
|
921
|
+
"exp",
|
|
922
|
+
"sqrt",
|
|
923
|
+
"log",
|
|
924
|
+
"sin",
|
|
925
|
+
"cos",
|
|
926
|
+
"tan",
|
|
927
|
+
"asin",
|
|
928
|
+
"acos",
|
|
929
|
+
"atan",
|
|
930
|
+
"Rational",
|
|
931
|
+
"erf",
|
|
932
|
+
]
|
|
933
|
+
if hasattr(sympy, k)
|
|
934
|
+
}
|
|
935
|
+
try:
|
|
936
|
+
expr = sympy.sympify(s, locals=allowed)
|
|
937
|
+
valf = float(expr.evalf())
|
|
938
|
+
_num_cache[s0] = valf
|
|
939
|
+
return valf
|
|
940
|
+
except Exception as e:
|
|
941
|
+
raise ValueError(f"Kunne ikke tolke numerisk uttrykk '{val}': {e}")
|
|
942
|
+
|
|
943
|
+
# Points
|
|
944
|
+
point_vals: List[Tuple[float, float]] = []
|
|
945
|
+
for p in lists.get("point", []):
|
|
946
|
+
lit = _safe_literal(p)
|
|
947
|
+
if isinstance(lit, (list, tuple)) and len(lit) == 2:
|
|
948
|
+
try:
|
|
949
|
+
x0 = _eval_expr(lit[0])
|
|
950
|
+
y0 = _eval_expr(lit[1])
|
|
951
|
+
point_vals.append((x0, y0))
|
|
952
|
+
except Exception:
|
|
953
|
+
pass
|
|
954
|
+
else:
|
|
955
|
+
# Support dynamic evaluation referencing previously defined function labels, e.g.
|
|
956
|
+
# (5, f(5)) or (2 - sqrt(2), f(2 - sqrt(2))).
|
|
957
|
+
# Simple pattern match for a parenthesized pair allowing arbitrary (non-comma) inner expressions.
|
|
958
|
+
ps = str(p).strip()
|
|
959
|
+
m_pair = re.match(r"^\(\s*([^,]+?)\s*,\s*([^,]+?)\s*\)$", ps)
|
|
960
|
+
if m_pair:
|
|
961
|
+
x_raw = m_pair.group(1).strip()
|
|
962
|
+
y_raw = m_pair.group(2).strip()
|
|
963
|
+
# Evaluate x and y as full expressions first; this already
|
|
964
|
+
# supports f(2 - sqrt(2)) via _eval_expr.
|
|
965
|
+
try:
|
|
966
|
+
x_val = _eval_expr(x_raw)
|
|
967
|
+
except Exception:
|
|
968
|
+
# If x itself references a function call label(arg),
|
|
969
|
+
# fall back to a simple numeric-argument pattern.
|
|
970
|
+
m_fx = re.match(
|
|
971
|
+
r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([+-]?(?:\d+(?:\.\d+)?))\s*\)$",
|
|
972
|
+
x_raw,
|
|
973
|
+
)
|
|
974
|
+
if m_fx:
|
|
975
|
+
lbl = m_fx.group(1)
|
|
976
|
+
arg = float(m_fx.group(2))
|
|
977
|
+
try:
|
|
978
|
+
idx = fn_labels_list.index(lbl)
|
|
979
|
+
x_val = float(functions[idx]([arg])[0])
|
|
980
|
+
except Exception:
|
|
981
|
+
continue # give up on this point
|
|
982
|
+
else:
|
|
983
|
+
continue
|
|
984
|
+
|
|
985
|
+
# y may be a direct expression or a function label call
|
|
986
|
+
# like f(2 - sqrt(2)). Try full expression evaluation first.
|
|
987
|
+
try:
|
|
988
|
+
y_val = _eval_expr(y_raw)
|
|
989
|
+
except Exception:
|
|
990
|
+
m_fy = re.match(
|
|
991
|
+
r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([+-]?(?:\d+(?:\.\d+)?))\s*\)$",
|
|
992
|
+
y_raw,
|
|
993
|
+
)
|
|
994
|
+
if not m_fy:
|
|
995
|
+
continue
|
|
996
|
+
lbl = m_fy.group(1)
|
|
997
|
+
arg = float(m_fy.group(2))
|
|
998
|
+
if lbl in fn_labels_list:
|
|
999
|
+
try:
|
|
1000
|
+
idx = fn_labels_list.index(lbl)
|
|
1001
|
+
y_val = float(functions[idx]([arg])[0])
|
|
1002
|
+
except Exception:
|
|
1003
|
+
continue
|
|
1004
|
+
else:
|
|
1005
|
+
continue
|
|
1006
|
+
try:
|
|
1007
|
+
point_vals.append((float(x_val), float(y_val)))
|
|
1008
|
+
except Exception:
|
|
1009
|
+
pass
|
|
1010
|
+
|
|
1011
|
+
# Tangents: can be specified either as
|
|
1012
|
+
# - "(x0, f(x0))" or "(x0, f(x0)), dashed, red"
|
|
1013
|
+
# - "x0, f [, style] [, color]" (preferred, simpler form)
|
|
1014
|
+
# In all cases, we draw the tangent to the function with label f at x0.
|
|
1015
|
+
tangent_vals: List[Tuple[float, float, float, str | None, str | None]] = (
|
|
1016
|
+
[]
|
|
1017
|
+
) # (a, b, x0, style, color)
|
|
1018
|
+
for t in lists.get("tangent", []):
|
|
1019
|
+
ps = str(t).strip()
|
|
1020
|
+
# Split on top-level commas so we can accept extra style/color tokens
|
|
1021
|
+
depth_t = 0
|
|
1022
|
+
cur_t: List[str] = []
|
|
1023
|
+
parts_t: List[str] = []
|
|
1024
|
+
for ch in ps:
|
|
1025
|
+
if ch == "(":
|
|
1026
|
+
depth_t += 1
|
|
1027
|
+
cur_t.append(ch)
|
|
1028
|
+
elif ch == ")":
|
|
1029
|
+
depth_t -= 1
|
|
1030
|
+
cur_t.append(ch)
|
|
1031
|
+
elif ch == "," and depth_t == 0:
|
|
1032
|
+
tok = "".join(cur_t).strip()
|
|
1033
|
+
if tok:
|
|
1034
|
+
parts_t.append(tok)
|
|
1035
|
+
cur_t = []
|
|
1036
|
+
else:
|
|
1037
|
+
cur_t.append(ch)
|
|
1038
|
+
tail_t = "".join(cur_t).strip()
|
|
1039
|
+
if tail_t:
|
|
1040
|
+
parts_t.append(tail_t)
|
|
1041
|
+
if not parts_t:
|
|
1042
|
+
continue
|
|
1043
|
+
|
|
1044
|
+
# Helper: parse optional style/color tokens
|
|
1045
|
+
def _parse_tangent_style(tokens: List[str]) -> Tuple[str | None, str | None]:
|
|
1046
|
+
style_t: str | None = None
|
|
1047
|
+
color_t: str | None = None
|
|
1048
|
+
_allowed_styles_t = {"solid", "dotted", "dashed", "dashdot"}
|
|
1049
|
+
for extra in tokens:
|
|
1050
|
+
tok = extra.strip().strip("'\"")
|
|
1051
|
+
low = tok.lower()
|
|
1052
|
+
if low in _allowed_styles_t and style_t is None:
|
|
1053
|
+
style_t = low
|
|
1054
|
+
elif color_t is None:
|
|
1055
|
+
color_t = tok
|
|
1056
|
+
return style_t, color_t
|
|
1057
|
+
|
|
1058
|
+
# First try the simple form: x0, f [, style] [, color]
|
|
1059
|
+
simple_ok = False
|
|
1060
|
+
if len(parts_t) >= 2:
|
|
1061
|
+
x0_raw = parts_t[0].strip()
|
|
1062
|
+
f_lbl_raw = parts_t[1].strip()
|
|
1063
|
+
# f_lbl_raw should look like a bare label, e.g. "f"
|
|
1064
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", f_lbl_raw):
|
|
1065
|
+
lbl = f_lbl_raw
|
|
1066
|
+
if lbl in fn_labels_list:
|
|
1067
|
+
try:
|
|
1068
|
+
x0 = _eval_expr(x0_raw)
|
|
1069
|
+
style_t, color_t = _parse_tangent_style(parts_t[2:])
|
|
1070
|
+
idx = fn_labels_list.index(lbl)
|
|
1071
|
+
f = functions[idx]
|
|
1072
|
+
import numpy as _np_t
|
|
1073
|
+
|
|
1074
|
+
# Finite-difference derivative around x0
|
|
1075
|
+
h = max(1e-5, 1e-5 * (1.0 + abs(x0)))
|
|
1076
|
+
y_plus = float(f(_np_t.array([x0 + h], dtype=float))[0])
|
|
1077
|
+
y_minus = float(f(_np_t.array([x0 - h], dtype=float))[0])
|
|
1078
|
+
a_t = (y_plus - y_minus) / (2 * h)
|
|
1079
|
+
y0 = float(f(_np_t.array([x0], dtype=float))[0])
|
|
1080
|
+
b_t = y0 - a_t * x0
|
|
1081
|
+
tangent_vals.append((a_t, b_t, x0, style_t, color_t))
|
|
1082
|
+
simple_ok = True
|
|
1083
|
+
except Exception:
|
|
1084
|
+
pass
|
|
1085
|
+
|
|
1086
|
+
if simple_ok:
|
|
1087
|
+
continue
|
|
1088
|
+
|
|
1089
|
+
# Fallback: old form where first part is the point pair (x0, f(x0))
|
|
1090
|
+
m_pair = re.match(r"^\(\s*([^,]+?)\s*,\s*([^,]+?)\s*\)$", parts_t[0])
|
|
1091
|
+
if not m_pair:
|
|
1092
|
+
continue
|
|
1093
|
+
x_raw = m_pair.group(1).strip()
|
|
1094
|
+
y_raw = m_pair.group(2).strip()
|
|
1095
|
+
# Expect y_raw to be a function label call like f(0.5)
|
|
1096
|
+
m_fy = re.match(
|
|
1097
|
+
r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([^()]+)\s*\)$",
|
|
1098
|
+
y_raw,
|
|
1099
|
+
)
|
|
1100
|
+
if not m_fy:
|
|
1101
|
+
continue
|
|
1102
|
+
lbl = m_fy.group(1)
|
|
1103
|
+
arg_expr = m_fy.group(2)
|
|
1104
|
+
if lbl not in fn_labels_list:
|
|
1105
|
+
continue
|
|
1106
|
+
try:
|
|
1107
|
+
x0 = _eval_expr(x_raw)
|
|
1108
|
+
_ = _eval_expr(arg_expr) # just ensure it's valid
|
|
1109
|
+
except Exception:
|
|
1110
|
+
continue
|
|
1111
|
+
style_t, color_t = _parse_tangent_style(parts_t[1:])
|
|
1112
|
+
try:
|
|
1113
|
+
idx = fn_labels_list.index(lbl)
|
|
1114
|
+
f = functions[idx]
|
|
1115
|
+
import numpy as _np_t
|
|
1116
|
+
|
|
1117
|
+
# Finite-difference derivative around x0
|
|
1118
|
+
h = max(1e-5, 1e-5 * (1.0 + abs(x0)))
|
|
1119
|
+
y_plus = float(f(_np_t.array([x0 + h], dtype=float))[0])
|
|
1120
|
+
y_minus = float(f(_np_t.array([x0 - h], dtype=float))[0])
|
|
1121
|
+
a_t = (y_plus - y_minus) / (2 * h)
|
|
1122
|
+
y0 = float(f(_np_t.array([x0], dtype=float))[0])
|
|
1123
|
+
b_t = y0 - a_t * x0
|
|
1124
|
+
tangent_vals.append((a_t, b_t, x0, style_t, color_t))
|
|
1125
|
+
except Exception:
|
|
1126
|
+
continue
|
|
1127
|
+
|
|
1128
|
+
# Annotations: [(xytext), (xy), "text", arc] OR without outer brackets:
|
|
1129
|
+
# (xytext), (xy), "text"[, arc]
|
|
1130
|
+
ann_vals: List[Tuple[Tuple[float, float], Tuple[float, float], str, float]] = []
|
|
1131
|
+
|
|
1132
|
+
def _annotate_fallback_parse(raw: str):
|
|
1133
|
+
"""Fallback parser for annotate lines containing arbitrary expressions.
|
|
1134
|
+
Expected pattern: (expr_x1, expr_y1), (expr_x2, expr_y2), "text"[, arc]
|
|
1135
|
+
Returns list of ( (x1,y1), (x2,y2), text, arc_expr|None ).
|
|
1136
|
+
"""
|
|
1137
|
+
s = raw.strip()
|
|
1138
|
+
out: List[Tuple[Tuple[str, str], Tuple[str, str], str, str | None]] = []
|
|
1139
|
+
|
|
1140
|
+
# Find first two balanced tuples
|
|
1141
|
+
def _grab_tuple(start_index: int) -> Tuple[int, int, str] | None:
|
|
1142
|
+
if start_index >= len(s) or s[start_index] != "(":
|
|
1143
|
+
return None
|
|
1144
|
+
depth = 0
|
|
1145
|
+
for j in range(start_index, len(s)):
|
|
1146
|
+
if s[j] == "(":
|
|
1147
|
+
depth += 1
|
|
1148
|
+
elif s[j] == ")":
|
|
1149
|
+
depth -= 1
|
|
1150
|
+
if depth == 0:
|
|
1151
|
+
inner = s[start_index + 1 : j]
|
|
1152
|
+
return (start_index, j, inner)
|
|
1153
|
+
return None
|
|
1154
|
+
|
|
1155
|
+
# locate first '('
|
|
1156
|
+
i1 = s.find("(")
|
|
1157
|
+
if i1 == -1:
|
|
1158
|
+
return out
|
|
1159
|
+
t1 = _grab_tuple(i1)
|
|
1160
|
+
if not t1:
|
|
1161
|
+
return out
|
|
1162
|
+
i2_search = t1[1] + 1
|
|
1163
|
+
# skip commas/space
|
|
1164
|
+
while i2_search < len(s) and s[i2_search] in " ,":
|
|
1165
|
+
i2_search += 1
|
|
1166
|
+
if i2_search >= len(s) or s[i2_search] != "(":
|
|
1167
|
+
return out
|
|
1168
|
+
t2 = _grab_tuple(i2_search)
|
|
1169
|
+
if not t2:
|
|
1170
|
+
return out
|
|
1171
|
+
rest = s[t2[1] + 1 :].strip()
|
|
1172
|
+
|
|
1173
|
+
# Split tuple inners on top-level comma
|
|
1174
|
+
def _split_pair(inner: str) -> Tuple[str, str] | None:
|
|
1175
|
+
depth = 0
|
|
1176
|
+
for k, ch in enumerate(inner):
|
|
1177
|
+
if ch == "(":
|
|
1178
|
+
depth += 1
|
|
1179
|
+
elif ch == ")":
|
|
1180
|
+
depth -= 1
|
|
1181
|
+
elif ch == "," and depth == 0:
|
|
1182
|
+
left = inner[:k].strip()
|
|
1183
|
+
right = inner[k + 1 :].strip()
|
|
1184
|
+
if left and right:
|
|
1185
|
+
return (left, right)
|
|
1186
|
+
return None
|
|
1187
|
+
|
|
1188
|
+
p1 = _split_pair(t1[2])
|
|
1189
|
+
p2 = _split_pair(t2[2])
|
|
1190
|
+
if not (p1 and p2):
|
|
1191
|
+
return out
|
|
1192
|
+
# Extract quoted text
|
|
1193
|
+
# Regex to capture either a double-quoted or single-quoted string
|
|
1194
|
+
m_txt = re.search(
|
|
1195
|
+
r"\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|'([^'\\]*(?:\\.[^'\\]*)*)'",
|
|
1196
|
+
rest,
|
|
1197
|
+
)
|
|
1198
|
+
if not m_txt:
|
|
1199
|
+
return out
|
|
1200
|
+
text = m_txt.group(1) if m_txt.group(1) is not None else m_txt.group(2)
|
|
1201
|
+
after = rest[m_txt.end() :].strip().lstrip(",").strip()
|
|
1202
|
+
arc_expr = after if after else None
|
|
1203
|
+
out.append(((p1[0], p1[1]), (p2[0], p2[1]), text, arc_expr))
|
|
1204
|
+
return out
|
|
1205
|
+
|
|
1206
|
+
for a in lists.get("annotate", []):
|
|
1207
|
+
lit = _safe_literal(a)
|
|
1208
|
+
# If user omitted surrounding brackets, try wrapping in [] for parsing
|
|
1209
|
+
if not (isinstance(lit, (list, tuple)) and len(lit) >= 3):
|
|
1210
|
+
lit_wrapped = _safe_literal(f"[{a}]")
|
|
1211
|
+
if isinstance(lit_wrapped, list) and len(lit_wrapped) >= 3:
|
|
1212
|
+
lit = lit_wrapped
|
|
1213
|
+
added_before = len(ann_vals)
|
|
1214
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 3:
|
|
1215
|
+
xytext, xy, text = lit[0], lit[1], lit[2]
|
|
1216
|
+
# arc (arrow curvature) may be an expression
|
|
1217
|
+
try:
|
|
1218
|
+
arc = _eval_expr(str(lit[3])) if len(lit) > 3 else 0.3
|
|
1219
|
+
except Exception:
|
|
1220
|
+
try:
|
|
1221
|
+
arc = float(lit[3]) if len(lit) > 3 else 0.3
|
|
1222
|
+
except Exception:
|
|
1223
|
+
arc = 0.3
|
|
1224
|
+
try:
|
|
1225
|
+
# Allow expression coordinates. Expect xytext and xy to be (x,y)-like iterables
|
|
1226
|
+
xyt0 = _eval_expr(str(xytext[0]))
|
|
1227
|
+
xyt1 = _eval_expr(str(xytext[1]))
|
|
1228
|
+
xy0 = _eval_expr(str(xy[0]))
|
|
1229
|
+
xy1 = _eval_expr(str(xy[1]))
|
|
1230
|
+
xytext = (float(xyt0), float(xyt1))
|
|
1231
|
+
xy = (float(xy0), float(xy1))
|
|
1232
|
+
text = str(text)
|
|
1233
|
+
ann_vals.append((xytext, xy, text, float(arc)))
|
|
1234
|
+
except Exception:
|
|
1235
|
+
# Fallback: attempt plain float conversion
|
|
1236
|
+
try:
|
|
1237
|
+
xytext = (float(xytext[0]), float(xytext[1]))
|
|
1238
|
+
xy = (float(xy[0]), float(xy[1]))
|
|
1239
|
+
text = str(text)
|
|
1240
|
+
ann_vals.append((xytext, xy, text, float(arc)))
|
|
1241
|
+
except Exception:
|
|
1242
|
+
pass
|
|
1243
|
+
# Fallback parsing if nothing appended
|
|
1244
|
+
if len(ann_vals) == added_before:
|
|
1245
|
+
for (
|
|
1246
|
+
(x1_expr, y1_expr),
|
|
1247
|
+
(x2_expr, y2_expr),
|
|
1248
|
+
text_s,
|
|
1249
|
+
arc_expr,
|
|
1250
|
+
) in _annotate_fallback_parse(str(a)):
|
|
1251
|
+
try:
|
|
1252
|
+
x1 = _eval_expr(x1_expr)
|
|
1253
|
+
y1 = _eval_expr(y1_expr)
|
|
1254
|
+
except Exception:
|
|
1255
|
+
continue
|
|
1256
|
+
try:
|
|
1257
|
+
x2 = _eval_expr(x2_expr)
|
|
1258
|
+
y2 = _eval_expr(y2_expr)
|
|
1259
|
+
except Exception:
|
|
1260
|
+
continue
|
|
1261
|
+
# arc expression optional
|
|
1262
|
+
arc_val = 0.3
|
|
1263
|
+
if arc_expr:
|
|
1264
|
+
try:
|
|
1265
|
+
arc_val = _eval_expr(arc_expr)
|
|
1266
|
+
except Exception:
|
|
1267
|
+
try:
|
|
1268
|
+
arc_val = float(arc_expr)
|
|
1269
|
+
except Exception:
|
|
1270
|
+
pass
|
|
1271
|
+
ann_vals.append(
|
|
1272
|
+
(
|
|
1273
|
+
(float(x1), float(y1)),
|
|
1274
|
+
(float(x2), float(y2)),
|
|
1275
|
+
str(text_s),
|
|
1276
|
+
float(arc_val),
|
|
1277
|
+
)
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
# Text: x, y, text, optional positioning, optional bbox flag
|
|
1281
|
+
# Accepted forms:
|
|
1282
|
+
# - [x, y, text]
|
|
1283
|
+
# - [x, y, text, pos]
|
|
1284
|
+
# - [x, y, text, bbox] # where bbox can be 'bbox' or a boolean (true/false)
|
|
1285
|
+
# - [x, y, text, pos, bbox]
|
|
1286
|
+
# CSV fallback supports the same arities; for 4 tokens, the 4th can be pos or bbox
|
|
1287
|
+
text_vals: List[Tuple[float, float, str, str, bool]] = []
|
|
1288
|
+
for t in lists.get("text", []):
|
|
1289
|
+
lit = _safe_literal(t)
|
|
1290
|
+
if isinstance(lit, (list, tuple)) and (3 <= len(lit) <= 5):
|
|
1291
|
+
try:
|
|
1292
|
+
# Allow expressions for x and y
|
|
1293
|
+
x = _eval_expr(str(lit[0]))
|
|
1294
|
+
y = _eval_expr(str(lit[1]))
|
|
1295
|
+
text = str(lit[2])
|
|
1296
|
+
pos = "top-left"
|
|
1297
|
+
bbox_flag = False
|
|
1298
|
+
if len(lit) == 4:
|
|
1299
|
+
token = lit[3]
|
|
1300
|
+
# If token is an explicit bbox flag
|
|
1301
|
+
if isinstance(token, str) and token.strip().lower() == "bbox":
|
|
1302
|
+
bbox_flag = True
|
|
1303
|
+
else:
|
|
1304
|
+
b = _parse_bool(token, default=None)
|
|
1305
|
+
if isinstance(b, bool):
|
|
1306
|
+
bbox_flag = bool(b)
|
|
1307
|
+
else:
|
|
1308
|
+
pos = str(token)
|
|
1309
|
+
elif len(lit) == 5:
|
|
1310
|
+
pos = str(lit[3])
|
|
1311
|
+
token = lit[4]
|
|
1312
|
+
if isinstance(token, str) and token.strip().lower() == "bbox":
|
|
1313
|
+
bbox_flag = True
|
|
1314
|
+
else:
|
|
1315
|
+
b = _parse_bool(token, default=None)
|
|
1316
|
+
if isinstance(b, bool):
|
|
1317
|
+
bbox_flag = bool(b)
|
|
1318
|
+
text_vals.append((x, y, text, pos, bbox_flag))
|
|
1319
|
+
continue
|
|
1320
|
+
except Exception:
|
|
1321
|
+
pass
|
|
1322
|
+
# Fallback: allow unquoted tokens like top-left using a CSV-style parse
|
|
1323
|
+
try:
|
|
1324
|
+
import csv
|
|
1325
|
+
|
|
1326
|
+
s = str(t).strip()
|
|
1327
|
+
# strip surrounding brackets/parentheses if present
|
|
1328
|
+
if (s.startswith("[") and s.endswith("]")) or (
|
|
1329
|
+
s.startswith("(") and s.endswith(")")
|
|
1330
|
+
):
|
|
1331
|
+
s = s[1:-1]
|
|
1332
|
+
# parse as a single CSV row
|
|
1333
|
+
row = next(csv.reader([s], skipinitialspace=True))
|
|
1334
|
+
if len(row) in (3, 4, 5):
|
|
1335
|
+
# Evaluate x,y as expressions
|
|
1336
|
+
x = _eval_expr(row[0].strip())
|
|
1337
|
+
y = _eval_expr(row[1].strip())
|
|
1338
|
+
text = row[2].strip()
|
|
1339
|
+
pos_keys = {
|
|
1340
|
+
"top-left",
|
|
1341
|
+
"top-right",
|
|
1342
|
+
"bottom-left",
|
|
1343
|
+
"bottom-right",
|
|
1344
|
+
"top-center",
|
|
1345
|
+
"bottom-center",
|
|
1346
|
+
"center-left",
|
|
1347
|
+
"center-right",
|
|
1348
|
+
}
|
|
1349
|
+
pos = "top-left"
|
|
1350
|
+
bbox_flag = False
|
|
1351
|
+
if len(row) == 4:
|
|
1352
|
+
tok = row[3].strip()
|
|
1353
|
+
if tok.lower() in pos_keys:
|
|
1354
|
+
pos = tok
|
|
1355
|
+
else:
|
|
1356
|
+
if tok.strip().lower() == "bbox":
|
|
1357
|
+
bbox_flag = True
|
|
1358
|
+
else:
|
|
1359
|
+
b = _parse_bool(tok, default=None)
|
|
1360
|
+
if isinstance(b, bool):
|
|
1361
|
+
bbox_flag = bool(b)
|
|
1362
|
+
else:
|
|
1363
|
+
# treat as position if not a boolean
|
|
1364
|
+
pos = tok
|
|
1365
|
+
elif len(row) == 5:
|
|
1366
|
+
pos = row[3].strip()
|
|
1367
|
+
tok = row[4].strip()
|
|
1368
|
+
if tok.strip().lower() == "bbox":
|
|
1369
|
+
bbox_flag = True
|
|
1370
|
+
else:
|
|
1371
|
+
b = _parse_bool(tok, default=None)
|
|
1372
|
+
if isinstance(b, bool):
|
|
1373
|
+
bbox_flag = bool(b)
|
|
1374
|
+
text_vals.append((x, y, text, pos, bbox_flag))
|
|
1375
|
+
except Exception:
|
|
1376
|
+
pass
|
|
1377
|
+
|
|
1378
|
+
# vlines: x[, ymin, ymax][, linestyle][, color] (style/color any order)
|
|
1379
|
+
vline_vals: List[Tuple[float, float | None, float | None, str | None, str | None]] = []
|
|
1380
|
+
_allowed_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
1381
|
+
for v in lists.get("vline", []):
|
|
1382
|
+
lit = _safe_literal(v)
|
|
1383
|
+
tokens: List[str] = []
|
|
1384
|
+
if isinstance(lit, (list, tuple)):
|
|
1385
|
+
tokens = [str(x).strip() for x in lit]
|
|
1386
|
+
else:
|
|
1387
|
+
tokens = [p.strip() for p in str(v).split(",") if p.strip()]
|
|
1388
|
+
|
|
1389
|
+
nums: List[float] = [] # evaluated numeric tokens (expressions allowed)
|
|
1390
|
+
extras: List[str] = [] # potential style/color tokens
|
|
1391
|
+
for t in tokens:
|
|
1392
|
+
# Attempt expression evaluation (supports arithmetic & function labels)
|
|
1393
|
+
try:
|
|
1394
|
+
val = _eval_expr(t)
|
|
1395
|
+
nums.append(val)
|
|
1396
|
+
continue
|
|
1397
|
+
except Exception:
|
|
1398
|
+
pass
|
|
1399
|
+
extras.append(t)
|
|
1400
|
+
x_val: float | None = None
|
|
1401
|
+
y0_val: float | None = None
|
|
1402
|
+
y1_val: float | None = None
|
|
1403
|
+
if len(nums) >= 1:
|
|
1404
|
+
x_val = nums[0]
|
|
1405
|
+
if len(nums) >= 3:
|
|
1406
|
+
y0_val, y1_val = nums[1], nums[2]
|
|
1407
|
+
|
|
1408
|
+
style: str | None = None
|
|
1409
|
+
color: str | None = None
|
|
1410
|
+
for e in extras:
|
|
1411
|
+
el = e.lower()
|
|
1412
|
+
if el in _allowed_styles and style is None:
|
|
1413
|
+
style = e
|
|
1414
|
+
elif color is None:
|
|
1415
|
+
color = e
|
|
1416
|
+
if x_val is not None:
|
|
1417
|
+
vline_vals.append((x_val, y0_val, y1_val, style, color))
|
|
1418
|
+
|
|
1419
|
+
# polygons: (x,y), (x,y), ... [ , show_vertices] [ , color] [ , alpha]
|
|
1420
|
+
# Extended: each coordinate may be an expression with user function calls.
|
|
1421
|
+
poly_vals: List[Tuple[List[Tuple[float, float]], bool, Optional[str], Optional[float]]] = []
|
|
1422
|
+
|
|
1423
|
+
# We avoid a complex fragile regex here and instead perform a small
|
|
1424
|
+
# balanced-parentheses scan so expressions like (2*sqrt(5), f(3+pi/4)) work.
|
|
1425
|
+
def _extract_coord_pairs(seq: str) -> List[Tuple[str, str]]:
|
|
1426
|
+
pairs: List[Tuple[str, str]] = []
|
|
1427
|
+
i = 0
|
|
1428
|
+
n = len(seq)
|
|
1429
|
+
while i < n:
|
|
1430
|
+
if seq[i] == "(": # potential tuple start
|
|
1431
|
+
depth = 0
|
|
1432
|
+
j = i
|
|
1433
|
+
while j < n:
|
|
1434
|
+
ch = seq[j]
|
|
1435
|
+
if ch == "(":
|
|
1436
|
+
depth += 1
|
|
1437
|
+
elif ch == ")":
|
|
1438
|
+
depth -= 1
|
|
1439
|
+
if depth == 0:
|
|
1440
|
+
inner = seq[i + 1 : j].strip()
|
|
1441
|
+
# split inner on a top-level comma
|
|
1442
|
+
depth2 = 0
|
|
1443
|
+
comma_index = -1
|
|
1444
|
+
for k, ch2 in enumerate(inner):
|
|
1445
|
+
if ch2 == "(":
|
|
1446
|
+
depth2 += 1
|
|
1447
|
+
elif ch2 == ")":
|
|
1448
|
+
depth2 -= 1
|
|
1449
|
+
elif ch2 == "," and depth2 == 0:
|
|
1450
|
+
comma_index = k
|
|
1451
|
+
break
|
|
1452
|
+
if comma_index != -1:
|
|
1453
|
+
x_expr = inner[:comma_index].strip()
|
|
1454
|
+
y_expr = inner[comma_index + 1 :].strip()
|
|
1455
|
+
if x_expr and y_expr:
|
|
1456
|
+
pairs.append((x_expr, y_expr))
|
|
1457
|
+
i = j # jump to end of tuple
|
|
1458
|
+
break
|
|
1459
|
+
j += 1
|
|
1460
|
+
i += 1
|
|
1461
|
+
return pairs
|
|
1462
|
+
|
|
1463
|
+
for p in lists.get("polygon", []):
|
|
1464
|
+
s = str(p).strip()
|
|
1465
|
+
show_vertices = False
|
|
1466
|
+
poly_color: Optional[str] = None
|
|
1467
|
+
poly_alpha: Optional[float] = None
|
|
1468
|
+
|
|
1469
|
+
# Extract show_vertices flag
|
|
1470
|
+
if re.search(r"(^|,)\s*show_vertices\s*(?=,|$)", s, flags=re.IGNORECASE):
|
|
1471
|
+
show_vertices = True
|
|
1472
|
+
s = re.sub(r"(^|,)\s*show_vertices\s*(?=,|$)", ",", s, flags=re.IGNORECASE)
|
|
1473
|
+
s = re.sub(r",{2,}", ",", s).strip().strip(",")
|
|
1474
|
+
|
|
1475
|
+
# Extract alpha if present
|
|
1476
|
+
alpha_match = re.search(r"(^|,)\s*([0-9]*\.?[0-9]+)\s*(?=,|$)", s)
|
|
1477
|
+
if alpha_match:
|
|
1478
|
+
try:
|
|
1479
|
+
potential_alpha = float(alpha_match.group(2))
|
|
1480
|
+
if 0 <= potential_alpha <= 1:
|
|
1481
|
+
poly_alpha = potential_alpha
|
|
1482
|
+
s = s[: alpha_match.start()] + s[alpha_match.end() :]
|
|
1483
|
+
s = re.sub(r",{2,}", ",", s).strip().strip(",")
|
|
1484
|
+
except Exception:
|
|
1485
|
+
pass
|
|
1486
|
+
|
|
1487
|
+
# Extract color (any remaining non-coordinate token)
|
|
1488
|
+
# First extract all coordinates
|
|
1489
|
+
temp_s = s
|
|
1490
|
+
for x_expr, y_expr in _extract_coord_pairs(temp_s):
|
|
1491
|
+
# Remove coordinate pairs from string to find leftover tokens
|
|
1492
|
+
temp_s = re.sub(
|
|
1493
|
+
r"\(\s*" + re.escape(x_expr) + r"\s*,\s*" + re.escape(y_expr) + r"\s*\)",
|
|
1494
|
+
"",
|
|
1495
|
+
temp_s,
|
|
1496
|
+
count=1,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
# Look for color token in remaining string
|
|
1500
|
+
remaining = re.sub(r"[(),\s]+", " ", temp_s).strip()
|
|
1501
|
+
tokens = [t.strip() for t in remaining.split() if t.strip()]
|
|
1502
|
+
if tokens:
|
|
1503
|
+
# First non-numeric token is likely the color
|
|
1504
|
+
for token in tokens:
|
|
1505
|
+
if not re.match(r"^[0-9]*\.?[0-9]+$", token):
|
|
1506
|
+
poly_color = token
|
|
1507
|
+
break
|
|
1508
|
+
|
|
1509
|
+
pts: List[Tuple[float, float]] = []
|
|
1510
|
+
for x_expr, y_expr in _extract_coord_pairs(s):
|
|
1511
|
+
try:
|
|
1512
|
+
xv = _eval_expr(x_expr)
|
|
1513
|
+
yv = _eval_expr(y_expr)
|
|
1514
|
+
pts.append((xv, yv))
|
|
1515
|
+
except Exception:
|
|
1516
|
+
# Ignore malformed or unevaluable pair
|
|
1517
|
+
pass
|
|
1518
|
+
if pts:
|
|
1519
|
+
poly_vals.append((pts, show_vertices, poly_color, poly_alpha))
|
|
1520
|
+
|
|
1521
|
+
# Re-introduce a plain numeric tuple matcher for other primitives still expecting numeric-only coordinates.
|
|
1522
|
+
tup_pat = re.compile(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)")
|
|
1523
|
+
|
|
1524
|
+
# bar: (x, y), length, orientation
|
|
1525
|
+
# Accept both literal list/tuple and CSV-like fallback, with expression evaluation
|
|
1526
|
+
bar_vals: List[Tuple[Tuple[float, float], float, str]] = []
|
|
1527
|
+
for b in lists.get("bar", []):
|
|
1528
|
+
lit = _safe_literal(b)
|
|
1529
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 3:
|
|
1530
|
+
try:
|
|
1531
|
+
xy_raw, length_raw, orient_raw = lit[0], lit[1], lit[2]
|
|
1532
|
+
# Use _eval_expr to support expressions in coordinates and length
|
|
1533
|
+
x_val = _eval_expr(xy_raw[0])
|
|
1534
|
+
y_val = _eval_expr(xy_raw[1])
|
|
1535
|
+
length = _eval_expr(length_raw)
|
|
1536
|
+
orientation = str(orient_raw).strip().lower()
|
|
1537
|
+
if orientation in {"h", "hor", "horiz", "horizontal"}:
|
|
1538
|
+
orientation = "horizontal"
|
|
1539
|
+
elif orientation in {"v", "vert", "vertical"}:
|
|
1540
|
+
orientation = "vertical"
|
|
1541
|
+
bar_vals.append(((x_val, y_val), length, orientation))
|
|
1542
|
+
continue
|
|
1543
|
+
except Exception:
|
|
1544
|
+
pass
|
|
1545
|
+
# Fallback: parse as string with (x,y), length, orientation
|
|
1546
|
+
try:
|
|
1547
|
+
s = str(b).strip()
|
|
1548
|
+
# Match coordinate pair allowing expressions: (expr, expr)
|
|
1549
|
+
m_coord = re.match(r"^\(\s*([^,]+?)\s*,\s*([^,]+?)\s*\)\s*,\s*(.+)$", s)
|
|
1550
|
+
if m_coord:
|
|
1551
|
+
x_expr = m_coord.group(1).strip()
|
|
1552
|
+
y_expr = m_coord.group(2).strip()
|
|
1553
|
+
rest = m_coord.group(3).strip()
|
|
1554
|
+
|
|
1555
|
+
# Evaluate x and y as expressions
|
|
1556
|
+
x_val = _eval_expr(x_expr)
|
|
1557
|
+
y_val = _eval_expr(y_expr)
|
|
1558
|
+
|
|
1559
|
+
# Split rest by comma to get length and orientation
|
|
1560
|
+
parts = [p.strip() for p in rest.split(",")]
|
|
1561
|
+
if len(parts) >= 2:
|
|
1562
|
+
length = _eval_expr(parts[0])
|
|
1563
|
+
orientation = parts[1].strip().lower()
|
|
1564
|
+
if orientation in {"h", "hor", "horiz", "horizontal"}:
|
|
1565
|
+
orientation = "horizontal"
|
|
1566
|
+
elif orientation in {"v", "vert", "vertical"}:
|
|
1567
|
+
orientation = "vertical"
|
|
1568
|
+
bar_vals.append(((x_val, y_val), length, orientation))
|
|
1569
|
+
except Exception:
|
|
1570
|
+
pass
|
|
1571
|
+
|
|
1572
|
+
hline_vals: List[Tuple[float, float | None, float | None, str | None, str | None]] = []
|
|
1573
|
+
for h in lists.get("hline", []):
|
|
1574
|
+
lit = _safe_literal(h)
|
|
1575
|
+
tokens_h: List[str] = []
|
|
1576
|
+
if isinstance(lit, (list, tuple)):
|
|
1577
|
+
tokens_h = [str(x).strip() for x in lit]
|
|
1578
|
+
else:
|
|
1579
|
+
tokens_h = [p.strip() for p in str(h).split(",") if p.strip()]
|
|
1580
|
+
|
|
1581
|
+
nums_h: List[float] = [] # numeric (expressions) for y, x0, x1
|
|
1582
|
+
extras_h: List[str] = [] # style/color tokens
|
|
1583
|
+
for t in tokens_h:
|
|
1584
|
+
try:
|
|
1585
|
+
val = _eval_expr(t)
|
|
1586
|
+
nums_h.append(val)
|
|
1587
|
+
continue
|
|
1588
|
+
except Exception:
|
|
1589
|
+
pass
|
|
1590
|
+
extras_h.append(t)
|
|
1591
|
+
y_val: float | None = None
|
|
1592
|
+
x0_val: float | None = None
|
|
1593
|
+
x1_val: float | None = None
|
|
1594
|
+
if len(nums_h) >= 1:
|
|
1595
|
+
y_val = nums_h[0]
|
|
1596
|
+
if len(nums_h) >= 3:
|
|
1597
|
+
x0_val, x1_val = nums_h[1], nums_h[2]
|
|
1598
|
+
|
|
1599
|
+
style_h: str | None = None
|
|
1600
|
+
color_h: str | None = None
|
|
1601
|
+
for e in extras_h:
|
|
1602
|
+
el = e.lower()
|
|
1603
|
+
if el in _allowed_styles and style_h is None:
|
|
1604
|
+
style_h = e
|
|
1605
|
+
elif color_h is None:
|
|
1606
|
+
color_h = e
|
|
1607
|
+
if y_val is not None:
|
|
1608
|
+
hline_vals.append((y_val, x0_val, x1_val, style_h, color_h))
|
|
1609
|
+
|
|
1610
|
+
# lines: a, b, color, linestyle OR a, (x, y), color, linestyle
|
|
1611
|
+
# color and linestyle optional and order-independent; linestyle defaults to dashed
|
|
1612
|
+
line_vals: List[Tuple[float, float, str | None, str | None]] = [] # (a, b, style, color)
|
|
1613
|
+
_allowed_styles_line = {"solid", "dotted", "dashed", "dashdot"}
|
|
1614
|
+
|
|
1615
|
+
def _split_top_level_line(val: str) -> List[str]:
|
|
1616
|
+
s = str(val or "").strip()
|
|
1617
|
+
if not s:
|
|
1618
|
+
return []
|
|
1619
|
+
# Only strip outer brackets/parens if they enclose the ENTIRE expression
|
|
1620
|
+
# Check if there's a top-level comma first
|
|
1621
|
+
depth = 0
|
|
1622
|
+
has_top_level_comma = False
|
|
1623
|
+
for ch in s:
|
|
1624
|
+
if ch in "([{":
|
|
1625
|
+
depth += 1
|
|
1626
|
+
elif ch in ")]}":
|
|
1627
|
+
depth -= 1
|
|
1628
|
+
elif ch == "," and depth == 0:
|
|
1629
|
+
has_top_level_comma = True
|
|
1630
|
+
break
|
|
1631
|
+
# Only strip outer brackets if NO top-level comma exists
|
|
1632
|
+
if not has_top_level_comma:
|
|
1633
|
+
if (s.startswith("[") and s.endswith("]")) or (
|
|
1634
|
+
s.startswith("(") and s.endswith(")")
|
|
1635
|
+
):
|
|
1636
|
+
s = s[1:-1].strip()
|
|
1637
|
+
out: List[str] = []
|
|
1638
|
+
cur: List[str] = []
|
|
1639
|
+
depth = 0
|
|
1640
|
+
for ch in s:
|
|
1641
|
+
if ch in "([{":
|
|
1642
|
+
depth += 1
|
|
1643
|
+
cur.append(ch)
|
|
1644
|
+
elif ch in ")]}":
|
|
1645
|
+
depth = max(0, depth - 1)
|
|
1646
|
+
cur.append(ch)
|
|
1647
|
+
elif ch == "," and depth == 0:
|
|
1648
|
+
part = "".join(cur).strip()
|
|
1649
|
+
if part:
|
|
1650
|
+
out.append(part)
|
|
1651
|
+
cur = []
|
|
1652
|
+
else:
|
|
1653
|
+
cur.append(ch)
|
|
1654
|
+
tail = "".join(cur).strip()
|
|
1655
|
+
if tail:
|
|
1656
|
+
out.append(tail)
|
|
1657
|
+
return out
|
|
1658
|
+
|
|
1659
|
+
# Helper to extract (x, y) coordinate pair from a string, handling nested parens and expressions
|
|
1660
|
+
def _extract_tuple_pair(s: str) -> Tuple[str, str] | None:
|
|
1661
|
+
"""Extract first balanced parenthesized pair (x_expr, y_expr) from string."""
|
|
1662
|
+
s = s.strip()
|
|
1663
|
+
if not s.startswith("("):
|
|
1664
|
+
return None
|
|
1665
|
+
depth = 0
|
|
1666
|
+
comma_idx = None
|
|
1667
|
+
for i, ch in enumerate(s):
|
|
1668
|
+
if ch == "(":
|
|
1669
|
+
depth += 1
|
|
1670
|
+
elif ch == ")":
|
|
1671
|
+
depth -= 1
|
|
1672
|
+
if depth == 0:
|
|
1673
|
+
# Found closing paren
|
|
1674
|
+
content = s[1:i]
|
|
1675
|
+
# Find top-level comma
|
|
1676
|
+
d2 = 0
|
|
1677
|
+
for j, c in enumerate(content):
|
|
1678
|
+
if c in "([{":
|
|
1679
|
+
d2 += 1
|
|
1680
|
+
elif c in ")]}":
|
|
1681
|
+
d2 -= 1
|
|
1682
|
+
elif c == "," and d2 == 0:
|
|
1683
|
+
comma_idx = j
|
|
1684
|
+
break
|
|
1685
|
+
if comma_idx is not None:
|
|
1686
|
+
x_expr = content[:comma_idx].strip()
|
|
1687
|
+
y_expr = content[comma_idx + 1 :].strip()
|
|
1688
|
+
return (x_expr, y_expr)
|
|
1689
|
+
return None
|
|
1690
|
+
elif ch == "," and depth == 1:
|
|
1691
|
+
if comma_idx is None:
|
|
1692
|
+
comma_idx = i
|
|
1693
|
+
return None
|
|
1694
|
+
|
|
1695
|
+
for l in lists.get("line", []):
|
|
1696
|
+
a_val: float | None = None
|
|
1697
|
+
b_val: float | None = None
|
|
1698
|
+
style_line: str | None = None
|
|
1699
|
+
color_line: str | None = None
|
|
1700
|
+
lit_line = _safe_literal(l)
|
|
1701
|
+
|
|
1702
|
+
# Check for new syntax: line: (x1, y1), (x2, y2)[, linestyle][, color]
|
|
1703
|
+
# First try to extract coordinate pairs to detect this syntax
|
|
1704
|
+
s_line = str(l).strip()
|
|
1705
|
+
coord_pairs = _extract_coord_pairs(s_line)
|
|
1706
|
+
if len(coord_pairs) >= 2:
|
|
1707
|
+
# New syntax detected: line through two points
|
|
1708
|
+
try:
|
|
1709
|
+
x1 = _eval_expr(coord_pairs[0][0])
|
|
1710
|
+
y1 = _eval_expr(coord_pairs[0][1])
|
|
1711
|
+
x2 = _eval_expr(coord_pairs[1][0])
|
|
1712
|
+
y2 = _eval_expr(coord_pairs[1][1])
|
|
1713
|
+
|
|
1714
|
+
# Calculate slope and intercept
|
|
1715
|
+
if abs(x2 - x1) > 1e-12: # avoid division by zero
|
|
1716
|
+
a_val = (y2 - y1) / (x2 - x1)
|
|
1717
|
+
b_val = y1 - a_val * x1
|
|
1718
|
+
|
|
1719
|
+
# Extract style and color from remaining parts
|
|
1720
|
+
# Remove the two coordinate pairs from the string
|
|
1721
|
+
rest = s_line
|
|
1722
|
+
for i in range(2):
|
|
1723
|
+
# Remove first occurrence of coordinate tuple
|
|
1724
|
+
rest = re.sub(r"\([^()]*,[^()]*\)", "", rest, count=1)
|
|
1725
|
+
rest = re.sub(r",{2,}", ",", rest).strip().strip(",")
|
|
1726
|
+
|
|
1727
|
+
# Parse style and color from remaining tokens
|
|
1728
|
+
tokens = [
|
|
1729
|
+
tok.strip().strip("'\"") for tok in rest.split(",") if tok.strip()
|
|
1730
|
+
]
|
|
1731
|
+
for tok in tokens:
|
|
1732
|
+
low = tok.lower()
|
|
1733
|
+
if low in _allowed_styles_line and style_line is None:
|
|
1734
|
+
style_line = low
|
|
1735
|
+
elif color_line is None:
|
|
1736
|
+
color_line = tok
|
|
1737
|
+
|
|
1738
|
+
line_vals.append((a_val, b_val, style_line, color_line))
|
|
1739
|
+
continue
|
|
1740
|
+
except Exception:
|
|
1741
|
+
pass # Fall through to old syntax parsing
|
|
1742
|
+
|
|
1743
|
+
# Old syntax parsing (backward compatibility)
|
|
1744
|
+
if isinstance(lit_line, (list, tuple)) and len(lit_line) >= 2:
|
|
1745
|
+
try:
|
|
1746
|
+
a_val = _eval_expr(lit_line[0])
|
|
1747
|
+
except Exception:
|
|
1748
|
+
a_val = None
|
|
1749
|
+
second = lit_line[1]
|
|
1750
|
+
if isinstance(second, (list, tuple)) and len(second) == 2:
|
|
1751
|
+
try:
|
|
1752
|
+
x0p = _eval_expr(second[0])
|
|
1753
|
+
y0p = _eval_expr(second[1])
|
|
1754
|
+
if a_val is not None:
|
|
1755
|
+
b_val = y0p - a_val * x0p
|
|
1756
|
+
except Exception:
|
|
1757
|
+
pass
|
|
1758
|
+
else:
|
|
1759
|
+
try:
|
|
1760
|
+
b_val = _eval_expr(second)
|
|
1761
|
+
except Exception:
|
|
1762
|
+
pass
|
|
1763
|
+
for extra in list(lit_line[2:]):
|
|
1764
|
+
if isinstance(extra, str):
|
|
1765
|
+
e = extra.strip().strip("\"'")
|
|
1766
|
+
if e.lower() in _allowed_styles_line and style_line is None:
|
|
1767
|
+
style_line = e.lower()
|
|
1768
|
+
elif color_line is None:
|
|
1769
|
+
color_line = e
|
|
1770
|
+
if a_val is not None and b_val is not None:
|
|
1771
|
+
line_vals.append((a_val, b_val, style_line, color_line))
|
|
1772
|
+
continue
|
|
1773
|
+
parts = _split_top_level_line(str(l))
|
|
1774
|
+
if len(parts) >= 2:
|
|
1775
|
+
try:
|
|
1776
|
+
a_val = _eval_expr(parts[0])
|
|
1777
|
+
except Exception:
|
|
1778
|
+
a_val = None
|
|
1779
|
+
# Check if second part is a tuple (x0, y0)
|
|
1780
|
+
tup_pair = _extract_tuple_pair(parts[1])
|
|
1781
|
+
if tup_pair is not None:
|
|
1782
|
+
try:
|
|
1783
|
+
x0p = _eval_expr(tup_pair[0])
|
|
1784
|
+
y0p = _eval_expr(tup_pair[1])
|
|
1785
|
+
if a_val is not None:
|
|
1786
|
+
b_val = y0p - a_val * x0p
|
|
1787
|
+
except Exception:
|
|
1788
|
+
pass
|
|
1789
|
+
else:
|
|
1790
|
+
try:
|
|
1791
|
+
b_val = _eval_expr(parts[1])
|
|
1792
|
+
except Exception:
|
|
1793
|
+
pass
|
|
1794
|
+
for extra in parts[2:]:
|
|
1795
|
+
e = str(extra).strip().strip("\"'")
|
|
1796
|
+
if e.lower() in _allowed_styles_line and style_line is None:
|
|
1797
|
+
style_line = e.lower()
|
|
1798
|
+
elif color_line is None:
|
|
1799
|
+
color_line = e
|
|
1800
|
+
if a_val is not None and b_val is not None:
|
|
1801
|
+
line_vals.append((a_val, b_val, style_line, color_line))
|
|
1802
|
+
|
|
1803
|
+
# fill-polygons: (x,y), (x,y), ... [, color] [, alpha]
|
|
1804
|
+
# Extended: coordinate expressions support arithmetic & function calls like polygons.
|
|
1805
|
+
# Defaults: color -> plotmath.COLORS.get("blue"), alpha -> 0.1
|
|
1806
|
+
poly_fill_vals: List[Tuple[List[Tuple[float, float]], str | None, float | None]] = []
|
|
1807
|
+
for fp in lists.get("fill-polygon", []):
|
|
1808
|
+
s = str(fp).strip()
|
|
1809
|
+
pts_fp: List[Tuple[float, float]] = []
|
|
1810
|
+
# Reuse polygon balanced-parentheses extractor defined earlier.
|
|
1811
|
+
# We reconstruct remaining extras by removing the matched tuple spans.
|
|
1812
|
+
consumed: List[Tuple[int, int]] = []
|
|
1813
|
+
for x_expr, y_expr in _extract_coord_pairs(s):
|
|
1814
|
+
# Find the substring to mark as consumed (naively search first occurrence of '(' + content + ')')
|
|
1815
|
+
pattern = f"({x_expr},{y_expr})" # simplified; if spaces existed they were stripped
|
|
1816
|
+
# We'll just evaluate; for extras we approximate removal by replacing once later.
|
|
1817
|
+
try:
|
|
1818
|
+
xv = _eval_expr(x_expr)
|
|
1819
|
+
yv = _eval_expr(y_expr)
|
|
1820
|
+
pts_fp.append((xv, yv))
|
|
1821
|
+
except Exception:
|
|
1822
|
+
pass
|
|
1823
|
+
# Remove coordinate tuples crudely: replace occurrences of '(...)' that correspond to points
|
|
1824
|
+
rest = s
|
|
1825
|
+
# Simple loop removing parenthesized pairs counted earlier; risk: may remove unrelated parentheses if same text repeats
|
|
1826
|
+
# but acceptable for directive usage.
|
|
1827
|
+
# A safer approach would replicate extraction with span tracking; to keep patch minimal we do a regex wipe of tuples.
|
|
1828
|
+
rest = re.sub(r"\([^()]*?,[^()]*?\)", "", rest)
|
|
1829
|
+
rest = re.sub(r",{2,}", ",", rest)
|
|
1830
|
+
extras = [tok.strip() for tok in rest.split(",") if tok.strip()]
|
|
1831
|
+
color_fp: str | None = None
|
|
1832
|
+
alpha_fp: float | None = None
|
|
1833
|
+
# Interpret extras in any order: first numeric -> alpha, first non-numeric -> color
|
|
1834
|
+
for tok in extras:
|
|
1835
|
+
if alpha_fp is None:
|
|
1836
|
+
try:
|
|
1837
|
+
alpha_fp = _eval_expr(tok)
|
|
1838
|
+
continue
|
|
1839
|
+
except Exception:
|
|
1840
|
+
pass
|
|
1841
|
+
if color_fp is None:
|
|
1842
|
+
color_fp = tok
|
|
1843
|
+
# Stop early if both parsed
|
|
1844
|
+
if color_fp is not None and alpha_fp is not None:
|
|
1845
|
+
break
|
|
1846
|
+
if pts_fp:
|
|
1847
|
+
poly_fill_vals.append((pts_fp, color_fp, alpha_fp))
|
|
1848
|
+
|
|
1849
|
+
# line-segment: (x1,y1), (x2,y2)[, linestyle][, color] (style/color optional, any order)
|
|
1850
|
+
line_segment_vals: List[
|
|
1851
|
+
Tuple[Tuple[float, float], Tuple[float, float], str | None, str | None]
|
|
1852
|
+
] = []
|
|
1853
|
+
_allowed_seg_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
1854
|
+
for ls in lists.get("line-segment", []):
|
|
1855
|
+
s = str(ls).strip()
|
|
1856
|
+
# Use the same balanced extractor as polygons but ensure exactly two points are taken.
|
|
1857
|
+
pairs = _extract_coord_pairs(s)
|
|
1858
|
+
if len(pairs) < 2:
|
|
1859
|
+
continue
|
|
1860
|
+
pcoords: List[Tuple[float, float]] = []
|
|
1861
|
+
for x_expr, y_expr in pairs[:2]:
|
|
1862
|
+
try:
|
|
1863
|
+
xv = _eval_expr(x_expr)
|
|
1864
|
+
yv = _eval_expr(y_expr)
|
|
1865
|
+
pcoords.append((float(xv), float(yv)))
|
|
1866
|
+
except Exception:
|
|
1867
|
+
pcoords = []
|
|
1868
|
+
break
|
|
1869
|
+
if len(pcoords) != 2:
|
|
1870
|
+
continue
|
|
1871
|
+
# Precisely remove the first two top-level tuples (with balanced parentheses)
|
|
1872
|
+
spans: List[Tuple[int, int]] = []
|
|
1873
|
+
depth = 0
|
|
1874
|
+
i = 0
|
|
1875
|
+
n = len(s)
|
|
1876
|
+
while i < n and len(spans) < 2:
|
|
1877
|
+
if s[i] == "(":
|
|
1878
|
+
depth = 1
|
|
1879
|
+
j = i + 1
|
|
1880
|
+
while j < n and depth > 0:
|
|
1881
|
+
if s[j] == "(":
|
|
1882
|
+
depth += 1
|
|
1883
|
+
elif s[j] == ")":
|
|
1884
|
+
depth -= 1
|
|
1885
|
+
j += 1
|
|
1886
|
+
if depth == 0:
|
|
1887
|
+
# captured tuple from i to j
|
|
1888
|
+
inner = s[i + 1 : j - 1]
|
|
1889
|
+
# verify it contains a top-level comma (treat as coordinate tuple)
|
|
1890
|
+
d2 = 0
|
|
1891
|
+
has_comma = False
|
|
1892
|
+
for ch in inner:
|
|
1893
|
+
if ch == "(":
|
|
1894
|
+
d2 += 1
|
|
1895
|
+
elif ch == ")":
|
|
1896
|
+
d2 -= 1
|
|
1897
|
+
elif ch == "," and d2 == 0:
|
|
1898
|
+
has_comma = True
|
|
1899
|
+
break
|
|
1900
|
+
if has_comma:
|
|
1901
|
+
spans.append((i, j))
|
|
1902
|
+
i = j
|
|
1903
|
+
else:
|
|
1904
|
+
i += 1
|
|
1905
|
+
# Build rest excluding spans
|
|
1906
|
+
if spans:
|
|
1907
|
+
parts_rest: List[str] = []
|
|
1908
|
+
last = 0
|
|
1909
|
+
for a, b in spans:
|
|
1910
|
+
if a > last:
|
|
1911
|
+
parts_rest.append(s[last:a])
|
|
1912
|
+
last = b
|
|
1913
|
+
if last < len(s):
|
|
1914
|
+
parts_rest.append(s[last:])
|
|
1915
|
+
rest = "".join(parts_rest)
|
|
1916
|
+
else:
|
|
1917
|
+
rest = s
|
|
1918
|
+
rest = re.sub(r",{2,}", ",", rest)
|
|
1919
|
+
tokens = [tok.strip().strip("'\"") for tok in rest.split(",") if tok.strip()]
|
|
1920
|
+
style_seg: str | None = None
|
|
1921
|
+
color_seg: str | None = None
|
|
1922
|
+
for tok in tokens:
|
|
1923
|
+
low = tok.lower()
|
|
1924
|
+
if low in _allowed_seg_styles and style_seg is None:
|
|
1925
|
+
style_seg = low
|
|
1926
|
+
continue
|
|
1927
|
+
if color_seg is None:
|
|
1928
|
+
# Accept token as color (will map later during draw)
|
|
1929
|
+
color_seg = tok
|
|
1930
|
+
line_segment_vals.append((pcoords[0], pcoords[1], style_seg, color_seg))
|
|
1931
|
+
|
|
1932
|
+
# axis commands: allow repeated keys like axis: equal / axis: off
|
|
1933
|
+
axis_cmds: List[str] = []
|
|
1934
|
+
for a in lists.get("axis", []):
|
|
1935
|
+
s = str(a).strip()
|
|
1936
|
+
# Allow comma-separated in one line as a convenience
|
|
1937
|
+
parts = [part.strip() for part in s.split(",") if part.strip()]
|
|
1938
|
+
for part in parts:
|
|
1939
|
+
# strip optional quotes
|
|
1940
|
+
if (part.startswith("'") and part.endswith("'")) or (
|
|
1941
|
+
part.startswith('"') and part.endswith('"')
|
|
1942
|
+
):
|
|
1943
|
+
part = part[1:-1].strip()
|
|
1944
|
+
if part:
|
|
1945
|
+
axis_cmds.append(part)
|
|
1946
|
+
|
|
1947
|
+
# vectors: support multiple syntaxes with expression support
|
|
1948
|
+
# 1. Legacy: x, y, dx, dy[, color]
|
|
1949
|
+
# 2. Point + components: (x, y), dx, dy[, color]
|
|
1950
|
+
# 3. Two points: (x1, y1), (x2, y2)[, color]
|
|
1951
|
+
vector_vals: List[Tuple[float, float, float, float, str]] = []
|
|
1952
|
+
for vline in lists.get("vector", []):
|
|
1953
|
+
s = str(vline).strip()
|
|
1954
|
+
# allow surrounding brackets/parentheses
|
|
1955
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
1956
|
+
s = s[1:-1].strip()
|
|
1957
|
+
|
|
1958
|
+
# Try to parse as literal first for tuple support
|
|
1959
|
+
lit = _safe_literal(vline)
|
|
1960
|
+
if isinstance(lit, (list, tuple)):
|
|
1961
|
+
try:
|
|
1962
|
+
# Check if first element is a tuple/list (coordinate pair format)
|
|
1963
|
+
if isinstance(lit[0], (list, tuple)) and len(lit[0]) == 2:
|
|
1964
|
+
# Format: (x, y), ... or [(x, y), ...]
|
|
1965
|
+
x_v = _eval_expr(lit[0][0])
|
|
1966
|
+
y_v = _eval_expr(lit[0][1])
|
|
1967
|
+
|
|
1968
|
+
if len(lit) >= 2 and isinstance(lit[1], (list, tuple)) and len(lit[1]) == 2:
|
|
1969
|
+
# Format: (x1, y1), (x2, y2)[, color]
|
|
1970
|
+
x2 = _eval_expr(lit[1][0])
|
|
1971
|
+
y2 = _eval_expr(lit[1][1])
|
|
1972
|
+
dx_v = x2 - x_v
|
|
1973
|
+
dy_v = y2 - y_v
|
|
1974
|
+
color_v = str(lit[2]).strip() if len(lit) >= 3 and lit[2] else "black"
|
|
1975
|
+
elif len(lit) >= 3:
|
|
1976
|
+
# Format: (x, y), dx, dy[, color]
|
|
1977
|
+
dx_v = _eval_expr(lit[1])
|
|
1978
|
+
dy_v = _eval_expr(lit[2])
|
|
1979
|
+
color_v = str(lit[3]).strip() if len(lit) >= 4 and lit[3] else "black"
|
|
1980
|
+
else:
|
|
1981
|
+
continue
|
|
1982
|
+
vector_vals.append((x_v, y_v, dx_v, dy_v, color_v))
|
|
1983
|
+
continue
|
|
1984
|
+
elif len(lit) >= 4:
|
|
1985
|
+
# Legacy format as literal: [x, y, dx, dy, color]
|
|
1986
|
+
x_v = _eval_expr(lit[0])
|
|
1987
|
+
y_v = _eval_expr(lit[1])
|
|
1988
|
+
dx_v = _eval_expr(lit[2])
|
|
1989
|
+
dy_v = _eval_expr(lit[3])
|
|
1990
|
+
color_v = str(lit[4]).strip() if len(lit) >= 5 and lit[4] else "black"
|
|
1991
|
+
vector_vals.append((x_v, y_v, dx_v, dy_v, color_v))
|
|
1992
|
+
continue
|
|
1993
|
+
except Exception:
|
|
1994
|
+
pass
|
|
1995
|
+
|
|
1996
|
+
# Parse as string: split by top-level commas
|
|
1997
|
+
depth_vec = 0
|
|
1998
|
+
cur_tok: List[str] = []
|
|
1999
|
+
parts: List[str] = []
|
|
2000
|
+
for ch in s:
|
|
2001
|
+
if ch in "([":
|
|
2002
|
+
depth_vec += 1
|
|
2003
|
+
cur_tok.append(ch)
|
|
2004
|
+
elif ch in ")]":
|
|
2005
|
+
depth_vec -= 1
|
|
2006
|
+
cur_tok.append(ch)
|
|
2007
|
+
elif ch == "," and depth_vec == 0:
|
|
2008
|
+
tok = "".join(cur_tok).strip()
|
|
2009
|
+
if tok:
|
|
2010
|
+
parts.append(tok)
|
|
2011
|
+
cur_tok = []
|
|
2012
|
+
else:
|
|
2013
|
+
cur_tok.append(ch)
|
|
2014
|
+
tail_tok = "".join(cur_tok).strip()
|
|
2015
|
+
if tail_tok:
|
|
2016
|
+
parts.append(tail_tok)
|
|
2017
|
+
|
|
2018
|
+
if len(parts) < 2:
|
|
2019
|
+
continue
|
|
2020
|
+
|
|
2021
|
+
try:
|
|
2022
|
+
# Check if first part is a coordinate pair
|
|
2023
|
+
first_part = parts[0].strip()
|
|
2024
|
+
if first_part.startswith("(") and first_part.endswith(")"):
|
|
2025
|
+
# Extract coordinates from first tuple
|
|
2026
|
+
coord_str = first_part[1:-1]
|
|
2027
|
+
coord_parts = coord_str.split(",")
|
|
2028
|
+
if len(coord_parts) != 2:
|
|
2029
|
+
continue
|
|
2030
|
+
x_v = _eval_expr(coord_parts[0].strip())
|
|
2031
|
+
y_v = _eval_expr(coord_parts[1].strip())
|
|
2032
|
+
|
|
2033
|
+
# Check second part
|
|
2034
|
+
if len(parts) >= 2:
|
|
2035
|
+
second_part = parts[1].strip()
|
|
2036
|
+
if second_part.startswith("(") and second_part.endswith(")"):
|
|
2037
|
+
# Format: (x1, y1), (x2, y2)[, color]
|
|
2038
|
+
coord2_str = second_part[1:-1]
|
|
2039
|
+
coord2_parts = coord2_str.split(",")
|
|
2040
|
+
if len(coord2_parts) != 2:
|
|
2041
|
+
continue
|
|
2042
|
+
x2 = _eval_expr(coord2_parts[0].strip())
|
|
2043
|
+
y2 = _eval_expr(coord2_parts[1].strip())
|
|
2044
|
+
dx_v = x2 - x_v
|
|
2045
|
+
dy_v = y2 - y_v
|
|
2046
|
+
color_v = (
|
|
2047
|
+
parts[2].strip()
|
|
2048
|
+
if len(parts) >= 3 and parts[2].strip()
|
|
2049
|
+
else "black"
|
|
2050
|
+
)
|
|
2051
|
+
elif len(parts) >= 3:
|
|
2052
|
+
# Format: (x, y), dx, dy[, color]
|
|
2053
|
+
dx_v = _eval_expr(parts[1])
|
|
2054
|
+
dy_v = _eval_expr(parts[2])
|
|
2055
|
+
color_v = (
|
|
2056
|
+
parts[3].strip()
|
|
2057
|
+
if len(parts) >= 4 and parts[3].strip()
|
|
2058
|
+
else "black"
|
|
2059
|
+
)
|
|
2060
|
+
else:
|
|
2061
|
+
continue
|
|
2062
|
+
else:
|
|
2063
|
+
continue
|
|
2064
|
+
elif len(parts) >= 4:
|
|
2065
|
+
# Legacy format: x, y, dx, dy[, color]
|
|
2066
|
+
x_v = _eval_expr(parts[0])
|
|
2067
|
+
y_v = _eval_expr(parts[1])
|
|
2068
|
+
dx_v = _eval_expr(parts[2])
|
|
2069
|
+
dy_v = _eval_expr(parts[3])
|
|
2070
|
+
color_v = parts[4].strip() if len(parts) >= 5 and parts[4].strip() else "black"
|
|
2071
|
+
else:
|
|
2072
|
+
continue
|
|
2073
|
+
|
|
2074
|
+
vector_vals.append((x_v, y_v, dx_v, dy_v, color_v))
|
|
2075
|
+
except Exception:
|
|
2076
|
+
# skip silently to preserve robustness
|
|
2077
|
+
continue
|
|
2078
|
+
|
|
2079
|
+
# angle-arc: (x, y), radius, start_angle_deg, end_angle_deg[, linestyle][, color]
|
|
2080
|
+
# Expression support for center, radius and angles; optional linestyle/color tokens in any order after the first three numeric expressions.
|
|
2081
|
+
angle_arcs: List[Tuple[float, float, float, float, float, str | None, str | None]] = []
|
|
2082
|
+
_allowed_arc_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
2083
|
+
for arc in lists.get("angle-arc", []):
|
|
2084
|
+
raw_arc = str(arc).strip()
|
|
2085
|
+
# Find first balanced parenthesis group for center
|
|
2086
|
+
idx_arc = raw_arc.find("(")
|
|
2087
|
+
if idx_arc == -1:
|
|
2088
|
+
continue
|
|
2089
|
+
depth_arc = 0
|
|
2090
|
+
end_center = -1
|
|
2091
|
+
for j in range(idx_arc, len(raw_arc)):
|
|
2092
|
+
ch = raw_arc[j]
|
|
2093
|
+
if ch == "(":
|
|
2094
|
+
depth_arc += 1
|
|
2095
|
+
elif ch == ")":
|
|
2096
|
+
depth_arc -= 1
|
|
2097
|
+
if depth_arc == 0:
|
|
2098
|
+
end_center = j
|
|
2099
|
+
break
|
|
2100
|
+
if end_center == -1:
|
|
2101
|
+
continue
|
|
2102
|
+
center_inner = raw_arc[idx_arc + 1 : end_center]
|
|
2103
|
+
# Split center_inner by top-level comma
|
|
2104
|
+
depth_c = 0
|
|
2105
|
+
cur_bits: List[str] = []
|
|
2106
|
+
center_parts: List[str] = []
|
|
2107
|
+
for ch in center_inner:
|
|
2108
|
+
if ch == "(":
|
|
2109
|
+
depth_c += 1
|
|
2110
|
+
cur_bits.append(ch)
|
|
2111
|
+
elif ch == ")":
|
|
2112
|
+
depth_c -= 1
|
|
2113
|
+
cur_bits.append(ch)
|
|
2114
|
+
elif ch == "," and depth_c == 0:
|
|
2115
|
+
token = "".join(cur_bits).strip()
|
|
2116
|
+
if token:
|
|
2117
|
+
center_parts.append(token)
|
|
2118
|
+
cur_bits = []
|
|
2119
|
+
else:
|
|
2120
|
+
cur_bits.append(ch)
|
|
2121
|
+
tail_cp = "".join(cur_bits).strip()
|
|
2122
|
+
if tail_cp:
|
|
2123
|
+
center_parts.append(tail_cp)
|
|
2124
|
+
if len(center_parts) != 2:
|
|
2125
|
+
continue
|
|
2126
|
+
rest_arc = raw_arc[end_center + 1 :].lstrip(",").strip()
|
|
2127
|
+
if not rest_arc:
|
|
2128
|
+
continue
|
|
2129
|
+
# Split rest by top-level commas
|
|
2130
|
+
depth_r = 0
|
|
2131
|
+
cur_r: List[str] = []
|
|
2132
|
+
tokens_r: List[str] = []
|
|
2133
|
+
for ch in rest_arc:
|
|
2134
|
+
if ch == "(":
|
|
2135
|
+
depth_r += 1
|
|
2136
|
+
cur_r.append(ch)
|
|
2137
|
+
elif ch == ")":
|
|
2138
|
+
depth_r -= 1
|
|
2139
|
+
cur_r.append(ch)
|
|
2140
|
+
elif ch == "," and depth_r == 0:
|
|
2141
|
+
tk = "".join(cur_r).strip()
|
|
2142
|
+
if tk:
|
|
2143
|
+
tokens_r.append(tk)
|
|
2144
|
+
cur_r = []
|
|
2145
|
+
else:
|
|
2146
|
+
cur_r.append(ch)
|
|
2147
|
+
tail_r = "".join(cur_r).strip()
|
|
2148
|
+
if tail_r:
|
|
2149
|
+
tokens_r.append(tail_r)
|
|
2150
|
+
if len(tokens_r) < 3:
|
|
2151
|
+
continue
|
|
2152
|
+
radius_expr = tokens_r[0]
|
|
2153
|
+
start_expr = tokens_r[1]
|
|
2154
|
+
end_expr = tokens_r[2]
|
|
2155
|
+
style_arc: str | None = None
|
|
2156
|
+
color_arc: str | None = None
|
|
2157
|
+
for extra_tok in tokens_r[3:]:
|
|
2158
|
+
low = extra_tok.lower()
|
|
2159
|
+
if low in _allowed_arc_styles and style_arc is None:
|
|
2160
|
+
style_arc = low
|
|
2161
|
+
elif color_arc is None:
|
|
2162
|
+
color_arc = extra_tok
|
|
2163
|
+
try:
|
|
2164
|
+
cx_val = float(_eval_expr(center_parts[0]))
|
|
2165
|
+
cy_val = float(_eval_expr(center_parts[1]))
|
|
2166
|
+
r_val = float(_eval_expr(radius_expr))
|
|
2167
|
+
if r_val <= 0:
|
|
2168
|
+
continue
|
|
2169
|
+
start_deg_val = float(_eval_expr(start_expr))
|
|
2170
|
+
end_deg_val = float(_eval_expr(end_expr))
|
|
2171
|
+
angle_arcs.append(
|
|
2172
|
+
(
|
|
2173
|
+
cx_val,
|
|
2174
|
+
cy_val,
|
|
2175
|
+
r_val,
|
|
2176
|
+
start_deg_val,
|
|
2177
|
+
end_deg_val,
|
|
2178
|
+
style_arc,
|
|
2179
|
+
color_arc,
|
|
2180
|
+
)
|
|
2181
|
+
)
|
|
2182
|
+
except Exception:
|
|
2183
|
+
continue
|
|
2184
|
+
|
|
2185
|
+
# circles: (x,y), radius[, linestyle][, color] (style/color optional, any order)
|
|
2186
|
+
# Accept expressions for x, y, radius. Optional tokens may appear in any order
|
|
2187
|
+
# after the radius token. Supported linestyles: solid, dotted, dashed, dashdot.
|
|
2188
|
+
circle_vals: List[Tuple[float, float, float, str | None, str | None]] = []
|
|
2189
|
+
_allowed_circle_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
2190
|
+
for c in lists.get("circle", []):
|
|
2191
|
+
raw = str(c).strip()
|
|
2192
|
+
# Expect something like: (expr_x, expr_y), radius_expr
|
|
2193
|
+
# We'll find first balanced tuple then split remaining by comma for radius.
|
|
2194
|
+
idx = raw.find("(")
|
|
2195
|
+
if idx == -1:
|
|
2196
|
+
continue
|
|
2197
|
+
# Grab balanced tuple
|
|
2198
|
+
depth = 0
|
|
2199
|
+
end_idx = -1
|
|
2200
|
+
for j in range(idx, len(raw)):
|
|
2201
|
+
if raw[j] == "(":
|
|
2202
|
+
depth += 1
|
|
2203
|
+
elif raw[j] == ")":
|
|
2204
|
+
depth -= 1
|
|
2205
|
+
if depth == 0:
|
|
2206
|
+
end_idx = j
|
|
2207
|
+
break
|
|
2208
|
+
if end_idx == -1:
|
|
2209
|
+
continue
|
|
2210
|
+
inner = raw[idx + 1 : end_idx]
|
|
2211
|
+
# split inner into x,y
|
|
2212
|
+
depth2 = 0
|
|
2213
|
+
comma_i = -1
|
|
2214
|
+
for k, ch in enumerate(inner):
|
|
2215
|
+
if ch == "(":
|
|
2216
|
+
depth2 += 1
|
|
2217
|
+
elif ch == ")":
|
|
2218
|
+
depth2 -= 1
|
|
2219
|
+
elif ch == "," and depth2 == 0:
|
|
2220
|
+
comma_i = k
|
|
2221
|
+
break
|
|
2222
|
+
if comma_i == -1:
|
|
2223
|
+
continue
|
|
2224
|
+
x_expr = inner[:comma_i].strip()
|
|
2225
|
+
y_expr = inner[comma_i + 1 :].strip()
|
|
2226
|
+
# Remaining after tuple for radius
|
|
2227
|
+
rest = raw[end_idx + 1 :].strip().lstrip(",").strip()
|
|
2228
|
+
if not rest:
|
|
2229
|
+
continue
|
|
2230
|
+
# Split rest into top-level comma tokens (radius + optional style/color)
|
|
2231
|
+
depth3 = 0
|
|
2232
|
+
tokens: List[str] = []
|
|
2233
|
+
cur: List[str] = []
|
|
2234
|
+
for ch in rest:
|
|
2235
|
+
if ch == "(":
|
|
2236
|
+
depth3 += 1
|
|
2237
|
+
cur.append(ch)
|
|
2238
|
+
elif ch == ")":
|
|
2239
|
+
depth3 -= 1
|
|
2240
|
+
cur.append(ch)
|
|
2241
|
+
elif ch == "," and depth3 == 0:
|
|
2242
|
+
part = "".join(cur).strip()
|
|
2243
|
+
if part:
|
|
2244
|
+
tokens.append(part)
|
|
2245
|
+
cur = []
|
|
2246
|
+
else:
|
|
2247
|
+
cur.append(ch)
|
|
2248
|
+
tail = "".join(cur).strip()
|
|
2249
|
+
if tail:
|
|
2250
|
+
tokens.append(tail)
|
|
2251
|
+
if not tokens:
|
|
2252
|
+
continue
|
|
2253
|
+
r_token = tokens[0]
|
|
2254
|
+
style_circle: str | None = None
|
|
2255
|
+
color_circle: str | None = None
|
|
2256
|
+
for tok in tokens[1:]:
|
|
2257
|
+
low = tok.lower()
|
|
2258
|
+
if low in _allowed_circle_styles and style_circle is None:
|
|
2259
|
+
style_circle = low
|
|
2260
|
+
elif color_circle is None:
|
|
2261
|
+
color_circle = tok
|
|
2262
|
+
try:
|
|
2263
|
+
xv = _eval_expr(x_expr)
|
|
2264
|
+
yv = _eval_expr(y_expr)
|
|
2265
|
+
rv = _eval_expr(r_token)
|
|
2266
|
+
if rv <= 0:
|
|
2267
|
+
continue
|
|
2268
|
+
circle_vals.append((float(xv), float(yv), float(rv), style_circle, color_circle))
|
|
2269
|
+
except Exception:
|
|
2270
|
+
# Silently skip invalid circle
|
|
2271
|
+
pass
|
|
2272
|
+
|
|
2273
|
+
# ellipses: (x0,y0), a, b[, linestyle][, color]
|
|
2274
|
+
# Parameterization: x = x0 + a*cos(t), y = y0 + b*sin(t), t in [0, 2*pi]
|
|
2275
|
+
ellipse_vals: List[Tuple[float, float, float, float, str | None, str | None]] = []
|
|
2276
|
+
_allowed_ellipse_styles = _allowed_circle_styles
|
|
2277
|
+
for e in lists.get("ellipse", []):
|
|
2278
|
+
raw = str(e).strip()
|
|
2279
|
+
idx = raw.find("(")
|
|
2280
|
+
if idx == -1:
|
|
2281
|
+
continue
|
|
2282
|
+
depth = 0
|
|
2283
|
+
end_idx = -1
|
|
2284
|
+
for j in range(idx, len(raw)):
|
|
2285
|
+
if raw[j] == "(":
|
|
2286
|
+
depth += 1
|
|
2287
|
+
elif raw[j] == ")":
|
|
2288
|
+
depth -= 1
|
|
2289
|
+
if depth == 0:
|
|
2290
|
+
end_idx = j
|
|
2291
|
+
break
|
|
2292
|
+
if end_idx == -1:
|
|
2293
|
+
continue
|
|
2294
|
+
inner = raw[idx + 1 : end_idx]
|
|
2295
|
+
# split inner center on top-level comma
|
|
2296
|
+
depth2 = 0
|
|
2297
|
+
comma_i = -1
|
|
2298
|
+
for k, ch in enumerate(inner):
|
|
2299
|
+
if ch == "(":
|
|
2300
|
+
depth2 += 1
|
|
2301
|
+
elif ch == ")":
|
|
2302
|
+
depth2 -= 1
|
|
2303
|
+
elif ch == "," and depth2 == 0:
|
|
2304
|
+
comma_i = k
|
|
2305
|
+
break
|
|
2306
|
+
if comma_i == -1:
|
|
2307
|
+
continue
|
|
2308
|
+
x0_expr = inner[:comma_i].strip()
|
|
2309
|
+
y0_expr = inner[comma_i + 1 :].strip()
|
|
2310
|
+
rest = raw[end_idx + 1 :].strip().lstrip(",").strip()
|
|
2311
|
+
if not rest:
|
|
2312
|
+
continue
|
|
2313
|
+
# tokenize rest top-level commas
|
|
2314
|
+
depth3 = 0
|
|
2315
|
+
tokens: List[str] = []
|
|
2316
|
+
cur: List[str] = []
|
|
2317
|
+
for ch in rest:
|
|
2318
|
+
if ch == "(":
|
|
2319
|
+
depth3 += 1
|
|
2320
|
+
cur.append(ch)
|
|
2321
|
+
elif ch == ")":
|
|
2322
|
+
depth3 -= 1
|
|
2323
|
+
cur.append(ch)
|
|
2324
|
+
elif ch == "," and depth3 == 0:
|
|
2325
|
+
part = "".join(cur).strip()
|
|
2326
|
+
if part:
|
|
2327
|
+
tokens.append(part)
|
|
2328
|
+
cur = []
|
|
2329
|
+
else:
|
|
2330
|
+
cur.append(ch)
|
|
2331
|
+
tail = "".join(cur).strip()
|
|
2332
|
+
if tail:
|
|
2333
|
+
tokens.append(tail)
|
|
2334
|
+
if len(tokens) < 2: # need a and b at least
|
|
2335
|
+
continue
|
|
2336
|
+
a_expr = tokens[0]
|
|
2337
|
+
b_expr = tokens[1]
|
|
2338
|
+
style_e: str | None = None
|
|
2339
|
+
color_e: str | None = None
|
|
2340
|
+
for tok in tokens[2:]:
|
|
2341
|
+
low = tok.lower()
|
|
2342
|
+
if low in _allowed_ellipse_styles and style_e is None:
|
|
2343
|
+
style_e = low
|
|
2344
|
+
elif color_e is None:
|
|
2345
|
+
color_e = tok
|
|
2346
|
+
try:
|
|
2347
|
+
x0v = _eval_expr(x0_expr)
|
|
2348
|
+
y0v = _eval_expr(y0_expr)
|
|
2349
|
+
av = _eval_expr(a_expr)
|
|
2350
|
+
bv = _eval_expr(b_expr)
|
|
2351
|
+
if av <= 0 or bv <= 0:
|
|
2352
|
+
continue
|
|
2353
|
+
ellipse_vals.append(
|
|
2354
|
+
(float(x0v), float(y0v), float(av), float(bv), style_e, color_e)
|
|
2355
|
+
)
|
|
2356
|
+
except Exception:
|
|
2357
|
+
pass
|
|
2358
|
+
|
|
2359
|
+
explicit_name = merged.get("name")
|
|
2360
|
+
debug_mode = "debug" in merged
|
|
2361
|
+
# curves: x_expr, y_expr, (t_start, t_end)[, linestyle][, color]
|
|
2362
|
+
curve_specs: List[Tuple[str, str, float, float, str | None, str | None]] = []
|
|
2363
|
+
_allowed_curve_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
2364
|
+
for c_line in lists.get("curve", []):
|
|
2365
|
+
s_line = str(c_line).strip()
|
|
2366
|
+
# Split top-level commas
|
|
2367
|
+
depth_c = 0
|
|
2368
|
+
parts_c: List[str] = []
|
|
2369
|
+
cur_c: List[str] = []
|
|
2370
|
+
for ch in s_line:
|
|
2371
|
+
if ch == "(":
|
|
2372
|
+
depth_c += 1
|
|
2373
|
+
cur_c.append(ch)
|
|
2374
|
+
elif ch == ")":
|
|
2375
|
+
depth_c -= 1
|
|
2376
|
+
cur_c.append(ch)
|
|
2377
|
+
elif ch == "," and depth_c == 0:
|
|
2378
|
+
token = "".join(cur_c).strip()
|
|
2379
|
+
if token:
|
|
2380
|
+
parts_c.append(token)
|
|
2381
|
+
cur_c = []
|
|
2382
|
+
else:
|
|
2383
|
+
cur_c.append(ch)
|
|
2384
|
+
tail_c = "".join(cur_c).strip()
|
|
2385
|
+
if tail_c:
|
|
2386
|
+
parts_c.append(tail_c)
|
|
2387
|
+
if len(parts_c) < 3:
|
|
2388
|
+
continue
|
|
2389
|
+
x_expr_c = parts_c[0]
|
|
2390
|
+
y_expr_c = parts_c[1]
|
|
2391
|
+
interval_token = parts_c[2]
|
|
2392
|
+
m_iv = re.match(r"^\(\s*(.+?)\s*,\s*(.+?)\s*\)$", interval_token)
|
|
2393
|
+
if not m_iv:
|
|
2394
|
+
continue
|
|
2395
|
+
t0_expr = m_iv.group(1)
|
|
2396
|
+
t1_expr = m_iv.group(2)
|
|
2397
|
+
style_cur: str | None = None
|
|
2398
|
+
color_cur: str | None = None
|
|
2399
|
+
for tok in parts_c[3:]:
|
|
2400
|
+
low = tok.lower()
|
|
2401
|
+
if low in _allowed_curve_styles and style_cur is None:
|
|
2402
|
+
style_cur = low
|
|
2403
|
+
elif color_cur is None:
|
|
2404
|
+
color_cur = tok
|
|
2405
|
+
try:
|
|
2406
|
+
t0_val = _eval_expr(t0_expr)
|
|
2407
|
+
t1_val = _eval_expr(t1_expr)
|
|
2408
|
+
if t1_val < t0_val:
|
|
2409
|
+
t0_val, t1_val = t1_val, t0_val
|
|
2410
|
+
curve_specs.append(
|
|
2411
|
+
(
|
|
2412
|
+
x_expr_c,
|
|
2413
|
+
y_expr_c,
|
|
2414
|
+
float(t0_val),
|
|
2415
|
+
float(t1_val),
|
|
2416
|
+
style_cur,
|
|
2417
|
+
color_cur,
|
|
2418
|
+
)
|
|
2419
|
+
)
|
|
2420
|
+
except Exception:
|
|
2421
|
+
continue
|
|
2422
|
+
|
|
2423
|
+
# Parse figsize early (string like (6,4) or [6,4]) but apply at end
|
|
2424
|
+
def _parse_figsize(val: Any):
|
|
2425
|
+
if not isinstance(val, str):
|
|
2426
|
+
return None
|
|
2427
|
+
s = val.strip()
|
|
2428
|
+
if not s:
|
|
2429
|
+
return None
|
|
2430
|
+
lit = _safe_literal(s)
|
|
2431
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 2:
|
|
2432
|
+
try:
|
|
2433
|
+
w = float(lit[0])
|
|
2434
|
+
h = float(lit[1])
|
|
2435
|
+
if w > 0 and h > 0:
|
|
2436
|
+
return (w, h)
|
|
2437
|
+
except Exception:
|
|
2438
|
+
return None
|
|
2439
|
+
# fallback simple regex (a,b)
|
|
2440
|
+
m = re.match(r"\(\s*([0-9]+(?:\.[0-9]+)?)\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)", s)
|
|
2441
|
+
if m:
|
|
2442
|
+
try:
|
|
2443
|
+
w = float(m.group(1))
|
|
2444
|
+
h = float(m.group(2))
|
|
2445
|
+
if w > 0 and h > 0:
|
|
2446
|
+
return (w, h)
|
|
2447
|
+
except Exception:
|
|
2448
|
+
return None
|
|
2449
|
+
return None
|
|
2450
|
+
|
|
2451
|
+
parsed_figsize = _parse_figsize(figsize_raw)
|
|
2452
|
+
|
|
2453
|
+
# Explicit LaTeX text rendering control. Default to True for consistent LaTeX fonts.
|
|
2454
|
+
# If the directive option is omitted, fall back to global config value 'plot_default_usetex' (defaults True).
|
|
2455
|
+
usetex_opt = _parse_bool(merged.get("usetex"), default=None)
|
|
2456
|
+
default_cfg = getattr(env.config, "plot_default_usetex", True)
|
|
2457
|
+
use_usetex = bool(usetex_opt) if usetex_opt is not None else bool(default_cfg)
|
|
2458
|
+
|
|
2459
|
+
# Hash includes all content affecting the image
|
|
2460
|
+
content_hash = _hash_key(
|
|
2461
|
+
"|".join(fn_exprs),
|
|
2462
|
+
"|".join(fn_labels_list),
|
|
2463
|
+
"|".join(["" if d is None else f"{d[0]},{d[1]}" for d in fn_domains_list]),
|
|
2464
|
+
"|".join([("|".join([str(x) for x in exs])) for exs in fn_exclusions_list]),
|
|
2465
|
+
";".join([f"{x},{y}" for x, y in point_vals]),
|
|
2466
|
+
";".join(
|
|
2467
|
+
[f"{xt[0]},{xt[1]}->{xy[0]},{xy[1]}:{t}:{arc}" for (xt, xy, t, arc) in ann_vals]
|
|
2468
|
+
),
|
|
2469
|
+
";".join(
|
|
2470
|
+
[
|
|
2471
|
+
(f"{x}" if y0 is None or y1 is None else f"{x},{y0},{y1}")
|
|
2472
|
+
+ f":{st or ''}:{col or ''}"
|
|
2473
|
+
for (x, y0, y1, st, col) in vline_vals
|
|
2474
|
+
]
|
|
2475
|
+
),
|
|
2476
|
+
";".join(
|
|
2477
|
+
[
|
|
2478
|
+
(f"{y}" if x0 is None or x1 is None else f"{y},{x0},{x1}")
|
|
2479
|
+
+ f":{st or ''}:{col or ''}"
|
|
2480
|
+
for (y, x0, x1, st, col) in hline_vals
|
|
2481
|
+
]
|
|
2482
|
+
),
|
|
2483
|
+
";".join(
|
|
2484
|
+
[
|
|
2485
|
+
f"{int(show)}:{poly_color or ''}:{poly_alpha or ''}:"
|
|
2486
|
+
+ "|".join([f"{x},{y}" for (x, y) in pts])
|
|
2487
|
+
for (pts, show, poly_color, poly_alpha) in poly_vals
|
|
2488
|
+
]
|
|
2489
|
+
),
|
|
2490
|
+
";".join(
|
|
2491
|
+
[
|
|
2492
|
+
(color or "")
|
|
2493
|
+
+ ":"
|
|
2494
|
+
+ ("" if alpha is None else str(alpha))
|
|
2495
|
+
+ ":"
|
|
2496
|
+
+ "|".join([f"{x},{y}" for (x, y) in pts])
|
|
2497
|
+
for (pts, color, alpha) in poly_fill_vals
|
|
2498
|
+
]
|
|
2499
|
+
),
|
|
2500
|
+
";".join(
|
|
2501
|
+
[
|
|
2502
|
+
f"{p1[0]},{p1[1]}->{p2[0]},{p2[1]}:{(st or '')}:{(col or '')}"
|
|
2503
|
+
for (p1, p2, st, col) in line_segment_vals
|
|
2504
|
+
]
|
|
2505
|
+
),
|
|
2506
|
+
";".join(
|
|
2507
|
+
[
|
|
2508
|
+
f"{cx},{cy}:{r}:{sa}:{ea}:{(st or '')}:{(col or '')}"
|
|
2509
|
+
for (cx, cy, r, sa, ea, st, col) in angle_arcs
|
|
2510
|
+
]
|
|
2511
|
+
),
|
|
2512
|
+
";".join(
|
|
2513
|
+
[
|
|
2514
|
+
f"{xy[0]},{xy[1]}:{length}:{orientation}"
|
|
2515
|
+
for (xy, length, orientation) in bar_vals
|
|
2516
|
+
]
|
|
2517
|
+
),
|
|
2518
|
+
";".join([f"{x},{y}:{dx},{dy}:{col}" for (x, y, dx, dy, col) in vector_vals]),
|
|
2519
|
+
"|".join(axis_cmds),
|
|
2520
|
+
";".join([f"{a},{b}:{(st or '')}:{(col or '')}" for (a, b, st, col) in line_vals]),
|
|
2521
|
+
";".join(
|
|
2522
|
+
[f"{a},{b}:{x0}:{(st or '')}:{(col or '')}" for (a, b, x0, st, col) in tangent_vals]
|
|
2523
|
+
),
|
|
2524
|
+
";".join(
|
|
2525
|
+
[
|
|
2526
|
+
f"{x},{y}:{txt}:{pos}:{int(1 if bbox else 0)}"
|
|
2527
|
+
for (x, y, txt, pos, bbox) in text_vals
|
|
2528
|
+
]
|
|
2529
|
+
),
|
|
2530
|
+
"|".join(fn_colors_list),
|
|
2531
|
+
xmin,
|
|
2532
|
+
xmax,
|
|
2533
|
+
ymin,
|
|
2534
|
+
ymax,
|
|
2535
|
+
xstep,
|
|
2536
|
+
ystep,
|
|
2537
|
+
fontsize,
|
|
2538
|
+
lw,
|
|
2539
|
+
alpha,
|
|
2540
|
+
str(merged.get("xlabel", "")),
|
|
2541
|
+
str(merged.get("ylabel", "")),
|
|
2542
|
+
int(bool(ticks_flag)),
|
|
2543
|
+
int(bool(grid_flag)),
|
|
2544
|
+
str(merged.get("xticks", "")),
|
|
2545
|
+
str(merged.get("yticks", "")),
|
|
2546
|
+
str(parsed_figsize),
|
|
2547
|
+
int(bool(use_usetex)),
|
|
2548
|
+
)
|
|
2549
|
+
base_name = explicit_name or f"plot_{content_hash}"
|
|
2550
|
+
|
|
2551
|
+
rel_dir = os.path.join("_static", "plot")
|
|
2552
|
+
abs_dir = os.path.join(app.srcdir, rel_dir)
|
|
2553
|
+
os.makedirs(abs_dir, exist_ok=True)
|
|
2554
|
+
svg_name = f"{base_name}.svg"
|
|
2555
|
+
abs_svg = os.path.join(abs_dir, svg_name)
|
|
2556
|
+
|
|
2557
|
+
regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
|
|
2558
|
+
if regenerate:
|
|
2559
|
+
import matplotlib
|
|
2560
|
+
|
|
2561
|
+
matplotlib.use("Agg")
|
|
2562
|
+
try:
|
|
2563
|
+
# Ensure consistent text rendering from drawing through save.
|
|
2564
|
+
_old_usetex = matplotlib.rcParams.get("text.usetex")
|
|
2565
|
+
_old_mathtext = matplotlib.rcParams.get("mathtext.fontset")
|
|
2566
|
+
try:
|
|
2567
|
+
matplotlib.rcParams["text.usetex"] = use_usetex
|
|
2568
|
+
# Prefer Computer Modern math text when not using external LaTeX
|
|
2569
|
+
if not use_usetex:
|
|
2570
|
+
matplotlib.rcParams["mathtext.fontset"] = "cm"
|
|
2571
|
+
except Exception:
|
|
2572
|
+
pass
|
|
2573
|
+
# Determine axis flags early
|
|
2574
|
+
axis_off = any(str(c).lower() == "off" for c in axis_cmds)
|
|
2575
|
+
axis_equal = any(str(c).lower() == "equal" for c in axis_cmds)
|
|
2576
|
+
|
|
2577
|
+
if axis_off:
|
|
2578
|
+
# Create a plain figure/axes (no plotmath.plot) so nothing (ticks/grid)
|
|
2579
|
+
# is drawn before we hide the axes.
|
|
2580
|
+
import matplotlib.pyplot as _plt
|
|
2581
|
+
|
|
2582
|
+
fig, ax = _plt.subplots()
|
|
2583
|
+
# Provide a reasonable default size similar to plotmath defaults
|
|
2584
|
+
fig.set_size_inches(6.0, 6.0)
|
|
2585
|
+
ax.set_xlim(xmin, xmax)
|
|
2586
|
+
ax.set_ylim(ymin, ymax)
|
|
2587
|
+
# Hide coordinate system
|
|
2588
|
+
try:
|
|
2589
|
+
ax.axis("off")
|
|
2590
|
+
except Exception:
|
|
2591
|
+
pass
|
|
2592
|
+
# Apply equal aspect if requested
|
|
2593
|
+
if axis_equal:
|
|
2594
|
+
try:
|
|
2595
|
+
ax.axis("equal")
|
|
2596
|
+
except Exception:
|
|
2597
|
+
pass
|
|
2598
|
+
else:
|
|
2599
|
+
# Standard path: delegate axis setup (ticks, grid, labels) to plotmath
|
|
2600
|
+
fig, ax = plotmath.plot(
|
|
2601
|
+
functions=[],
|
|
2602
|
+
fn_labels=False,
|
|
2603
|
+
xmin=xmin,
|
|
2604
|
+
xmax=xmax,
|
|
2605
|
+
ymin=ymin,
|
|
2606
|
+
ymax=ymax,
|
|
2607
|
+
xstep=xstep,
|
|
2608
|
+
ystep=ystep,
|
|
2609
|
+
ticks=ticks_flag,
|
|
2610
|
+
grid=grid_flag,
|
|
2611
|
+
lw=lw,
|
|
2612
|
+
alpha=alpha,
|
|
2613
|
+
fontsize=fontsize,
|
|
2614
|
+
)
|
|
2615
|
+
# If equal requested (without off), apply after plot creation
|
|
2616
|
+
if axis_equal:
|
|
2617
|
+
try:
|
|
2618
|
+
ax.axis("equal")
|
|
2619
|
+
except Exception:
|
|
2620
|
+
pass
|
|
2621
|
+
|
|
2622
|
+
# Plot requested functions directly on ax, with optional labels, per-function domains, and exclusions
|
|
2623
|
+
if functions:
|
|
2624
|
+
import numpy as np
|
|
2625
|
+
|
|
2626
|
+
# Helper function to draw endpoint markers
|
|
2627
|
+
def _draw_endpoint_marker(
|
|
2628
|
+
ax, x_arr, y_arr, idx, marker_type, direction, color_use, lw_use
|
|
2629
|
+
):
|
|
2630
|
+
"""Draw endpoint marker orthogonal to curve at endpoint.
|
|
2631
|
+
|
|
2632
|
+
Parameters:
|
|
2633
|
+
- ax: matplotlib axes
|
|
2634
|
+
- x_arr, y_arr: full arrays of x and y data for the curve
|
|
2635
|
+
- idx: index of the endpoint (0 for left, -1 for right)
|
|
2636
|
+
- marker_type: "closed" (bracket) or "open" (angle bracket)
|
|
2637
|
+
- direction: "left" or "right" indicating which endpoint
|
|
2638
|
+
- color_use: line color
|
|
2639
|
+
- lw_use: line width
|
|
2640
|
+
"""
|
|
2641
|
+
if marker_type not in ["closed", "open"]:
|
|
2642
|
+
return
|
|
2643
|
+
|
|
2644
|
+
x_pt = x_arr[idx]
|
|
2645
|
+
y_pt = y_arr[idx]
|
|
2646
|
+
|
|
2647
|
+
# Calculate tangent vector at endpoint using nearby points
|
|
2648
|
+
if direction == "left":
|
|
2649
|
+
# Use forward difference for left endpoint
|
|
2650
|
+
# Find next few finite points
|
|
2651
|
+
tangent_idx = None
|
|
2652
|
+
for i in range(1, min(10, len(x_arr))):
|
|
2653
|
+
if np.isfinite(y_arr[i]):
|
|
2654
|
+
tangent_idx = i
|
|
2655
|
+
break
|
|
2656
|
+
if tangent_idx is None:
|
|
2657
|
+
return # No valid points to compute tangent
|
|
2658
|
+
dx = x_arr[tangent_idx] - x_pt
|
|
2659
|
+
dy = y_arr[tangent_idx] - y_pt
|
|
2660
|
+
else: # right endpoint
|
|
2661
|
+
# Use backward difference for right endpoint
|
|
2662
|
+
# Find previous few finite points
|
|
2663
|
+
tangent_idx = None
|
|
2664
|
+
for i in range(len(x_arr) - 2, max(-1, len(x_arr) - 11), -1):
|
|
2665
|
+
if np.isfinite(y_arr[i]):
|
|
2666
|
+
tangent_idx = i
|
|
2667
|
+
break
|
|
2668
|
+
if tangent_idx is None:
|
|
2669
|
+
return # No valid points to compute tangent
|
|
2670
|
+
dx = x_pt - x_arr[tangent_idx]
|
|
2671
|
+
dy = y_pt - y_arr[tangent_idx]
|
|
2672
|
+
|
|
2673
|
+
# Get axes limits and figure dimensions first
|
|
2674
|
+
xlim = ax.get_xlim()
|
|
2675
|
+
ylim = ax.get_ylim()
|
|
2676
|
+
x_range = xlim[1] - xlim[0]
|
|
2677
|
+
y_range = ylim[1] - ylim[0]
|
|
2678
|
+
|
|
2679
|
+
# Get pixel dimensions of axes
|
|
2680
|
+
try:
|
|
2681
|
+
bbox = ax.get_window_extent()
|
|
2682
|
+
ax_width_px = bbox.width
|
|
2683
|
+
ax_height_px = bbox.height
|
|
2684
|
+
except Exception:
|
|
2685
|
+
# Fallback if we can't get pixel dimensions
|
|
2686
|
+
ax_width_px = 600
|
|
2687
|
+
ax_height_px = 600
|
|
2688
|
+
|
|
2689
|
+
# Convert tangent vector from data coordinates to display (pixel) coordinates
|
|
2690
|
+
# This preserves angles visually on screen
|
|
2691
|
+
data_to_px_x = ax_width_px / x_range if x_range > 0 else 1.0
|
|
2692
|
+
data_to_px_y = ax_height_px / y_range if y_range > 0 else 1.0
|
|
2693
|
+
|
|
2694
|
+
dx_px = dx * data_to_px_x
|
|
2695
|
+
dy_px = dy * data_to_px_y
|
|
2696
|
+
|
|
2697
|
+
# Normalize tangent vector in pixel space
|
|
2698
|
+
tangent_length_px = np.sqrt(dx_px**2 + dy_px**2)
|
|
2699
|
+
if tangent_length_px < 1e-10:
|
|
2700
|
+
# Fallback to horizontal tangent in pixel space
|
|
2701
|
+
tx_px, ty_px = 1.0, 0.0
|
|
2702
|
+
else:
|
|
2703
|
+
tx_px = dx_px / tangent_length_px
|
|
2704
|
+
ty_px = dy_px / tangent_length_px
|
|
2705
|
+
|
|
2706
|
+
# Compute orthogonal vector in pixel space (rotate tangent 90° CCW)
|
|
2707
|
+
# This ensures the orthogonal is truly perpendicular visually
|
|
2708
|
+
ortho_x_px = -ty_px
|
|
2709
|
+
ortho_y_px = tx_px
|
|
2710
|
+
|
|
2711
|
+
# Base marker size in pixels (invariant to axis limits)
|
|
2712
|
+
marker_size_px = 25.0
|
|
2713
|
+
|
|
2714
|
+
# Scale orthogonal vector to desired pixel length
|
|
2715
|
+
ortho_x_px_scaled = ortho_x_px * marker_size_px
|
|
2716
|
+
ortho_y_px_scaled = ortho_y_px * marker_size_px
|
|
2717
|
+
|
|
2718
|
+
# Convert back to data coordinates for plotting
|
|
2719
|
+
px_to_data_x = x_range / ax_width_px if ax_width_px > 0 else 0.01
|
|
2720
|
+
px_to_data_y = y_range / ax_height_px if ax_height_px > 0 else 0.01
|
|
2721
|
+
|
|
2722
|
+
ortho_x_scaled = ortho_x_px_scaled * px_to_data_x
|
|
2723
|
+
ortho_y_scaled = ortho_y_px_scaled * px_to_data_y
|
|
2724
|
+
|
|
2725
|
+
# Similarly, convert tangent vector for caps
|
|
2726
|
+
tx = tx_px * px_to_data_x
|
|
2727
|
+
ty = ty_px * px_to_data_y
|
|
2728
|
+
# Normalize in data space for unit tangent
|
|
2729
|
+
t_norm = np.sqrt(tx**2 + ty**2)
|
|
2730
|
+
if t_norm > 1e-10:
|
|
2731
|
+
tx = tx / t_norm
|
|
2732
|
+
ty = ty / t_norm
|
|
2733
|
+
|
|
2734
|
+
if marker_type == "closed":
|
|
2735
|
+
# Draw bracket: main line perpendicular to curve with short caps along tangent
|
|
2736
|
+
# The bracket extends along the orthogonal direction
|
|
2737
|
+
# Half above, half below the endpoint
|
|
2738
|
+
|
|
2739
|
+
# Main perpendicular line
|
|
2740
|
+
x_main = [x_pt - ortho_x_scaled / 2, x_pt + ortho_x_scaled / 2]
|
|
2741
|
+
y_main = [y_pt - ortho_y_scaled / 2, y_pt + ortho_y_scaled / 2]
|
|
2742
|
+
ax.plot(
|
|
2743
|
+
x_main,
|
|
2744
|
+
y_main,
|
|
2745
|
+
color=color_use,
|
|
2746
|
+
lw=lw_use * 0.8,
|
|
2747
|
+
solid_capstyle="butt",
|
|
2748
|
+
zorder=10,
|
|
2749
|
+
)
|
|
2750
|
+
|
|
2751
|
+
# Cap length along the tangent direction (pixel-invariant)
|
|
2752
|
+
cap_length_px = 8.0
|
|
2753
|
+
# Scale tangent in pixel space, then convert to data
|
|
2754
|
+
cap_tx_px = tx_px * cap_length_px
|
|
2755
|
+
cap_ty_px = ty_px * cap_length_px
|
|
2756
|
+
cap_tx = cap_tx_px * px_to_data_x
|
|
2757
|
+
cap_ty = cap_ty_px * px_to_data_y
|
|
2758
|
+
|
|
2759
|
+
# Determine cap direction based on endpoint
|
|
2760
|
+
if direction == "left":
|
|
2761
|
+
# Caps point inward (to the right/into the curve)
|
|
2762
|
+
cap_sign = 1.0
|
|
2763
|
+
else:
|
|
2764
|
+
# Caps point inward (to the left/into the curve)
|
|
2765
|
+
cap_sign = -1.0
|
|
2766
|
+
|
|
2767
|
+
# Top cap (at +ortho end)
|
|
2768
|
+
x_top_end = x_pt + ortho_x_scaled / 2
|
|
2769
|
+
y_top_end = y_pt + ortho_y_scaled / 2
|
|
2770
|
+
ax.plot(
|
|
2771
|
+
[x_top_end, x_top_end + cap_sign * cap_tx],
|
|
2772
|
+
[y_top_end, y_top_end + cap_sign * cap_ty],
|
|
2773
|
+
color=color_use,
|
|
2774
|
+
lw=lw_use * 0.8,
|
|
2775
|
+
solid_capstyle="butt",
|
|
2776
|
+
zorder=10,
|
|
2777
|
+
)
|
|
2778
|
+
|
|
2779
|
+
# Bottom cap (at -ortho end)
|
|
2780
|
+
x_bot_end = x_pt - ortho_x_scaled / 2
|
|
2781
|
+
y_bot_end = y_pt - ortho_y_scaled / 2
|
|
2782
|
+
ax.plot(
|
|
2783
|
+
[x_bot_end, x_bot_end + cap_sign * cap_tx],
|
|
2784
|
+
[y_bot_end, y_bot_end + cap_sign * cap_ty],
|
|
2785
|
+
color=color_use,
|
|
2786
|
+
lw=lw_use * 0.8,
|
|
2787
|
+
solid_capstyle="butt",
|
|
2788
|
+
zorder=10,
|
|
2789
|
+
)
|
|
2790
|
+
|
|
2791
|
+
elif marker_type == "open":
|
|
2792
|
+
# Draw angle bracket: two lines forming < or >
|
|
2793
|
+
# The tip connects to the curve, arms extend outward
|
|
2794
|
+
|
|
2795
|
+
# Angle opening along tangent direction (pixel-invariant)
|
|
2796
|
+
angle_length_px = 8.0
|
|
2797
|
+
# Scale tangent in pixel space, then convert to data
|
|
2798
|
+
angle_tx_px = tx_px * angle_length_px
|
|
2799
|
+
angle_ty_px = ty_px * angle_length_px
|
|
2800
|
+
angle_tx = angle_tx_px * px_to_data_x
|
|
2801
|
+
angle_ty = angle_ty_px * px_to_data_y
|
|
2802
|
+
|
|
2803
|
+
# Determine angle direction based on endpoint
|
|
2804
|
+
if direction == "left":
|
|
2805
|
+
# Angle opens to the right (inward toward curve)
|
|
2806
|
+
angle_sign = 1.0
|
|
2807
|
+
else:
|
|
2808
|
+
# Angle opens to the left (inward toward curve)
|
|
2809
|
+
angle_sign = -1.0
|
|
2810
|
+
|
|
2811
|
+
# The tip is at the actual curve point (x_pt, y_pt)
|
|
2812
|
+
# Arms extend from tip along both orthogonal directions and tangent
|
|
2813
|
+
|
|
2814
|
+
# Upper arm: from the curve point to upper outer position
|
|
2815
|
+
x_upper_outer = x_pt + angle_sign * angle_tx + ortho_x_scaled / 2
|
|
2816
|
+
y_upper_outer = y_pt + angle_sign * angle_ty + ortho_y_scaled / 2
|
|
2817
|
+
ax.plot(
|
|
2818
|
+
[x_pt, x_upper_outer],
|
|
2819
|
+
[y_pt, y_upper_outer],
|
|
2820
|
+
color=color_use,
|
|
2821
|
+
lw=lw_use * 0.8,
|
|
2822
|
+
solid_capstyle="butt",
|
|
2823
|
+
zorder=10,
|
|
2824
|
+
)
|
|
2825
|
+
|
|
2826
|
+
# Lower arm: from the curve point to lower outer position
|
|
2827
|
+
x_lower_outer = x_pt + angle_sign * angle_tx - ortho_x_scaled / 2
|
|
2828
|
+
y_lower_outer = y_pt + angle_sign * angle_ty - ortho_y_scaled / 2
|
|
2829
|
+
ax.plot(
|
|
2830
|
+
[x_pt, x_lower_outer],
|
|
2831
|
+
[y_pt, y_lower_outer],
|
|
2832
|
+
color=color_use,
|
|
2833
|
+
lw=lw_use * 0.8,
|
|
2834
|
+
solid_capstyle="butt",
|
|
2835
|
+
zorder=10,
|
|
2836
|
+
)
|
|
2837
|
+
|
|
2838
|
+
any_label = False
|
|
2839
|
+
for f, lbl, dom, exs, col_fun, endpoints in zip(
|
|
2840
|
+
functions,
|
|
2841
|
+
fn_labels_list,
|
|
2842
|
+
fn_domains_list,
|
|
2843
|
+
fn_exclusions_list,
|
|
2844
|
+
fn_colors_list,
|
|
2845
|
+
fn_endpoints_list,
|
|
2846
|
+
):
|
|
2847
|
+
x0, x1 = dom if dom is not None else (xmin, xmax)
|
|
2848
|
+
N = int(2**14) # 16384 points for smooth curves
|
|
2849
|
+
x = np.linspace(x0, x1, N)
|
|
2850
|
+
y = f(x)
|
|
2851
|
+
# Ensure float array and blank out non-finite values
|
|
2852
|
+
y = np.asarray(y, dtype=float)
|
|
2853
|
+
y[~np.isfinite(y)] = np.nan
|
|
2854
|
+
# More robust exclusion handling: blank a window around each excluded x
|
|
2855
|
+
exs_in = [e for e in exs if x0 < e < x1]
|
|
2856
|
+
if exs_in and N > 1:
|
|
2857
|
+
dx = (x1 - x0) / (N - 1)
|
|
2858
|
+
# Window width larger than step to ensure a gap; include tiny absolute floor
|
|
2859
|
+
w = max(4 * dx, 1e-6 * (1.0 + max(abs(e) for e in exs_in)))
|
|
2860
|
+
for e in exs_in:
|
|
2861
|
+
try:
|
|
2862
|
+
mask = np.abs(x - e) <= w
|
|
2863
|
+
if mask.any():
|
|
2864
|
+
y[mask] = np.nan
|
|
2865
|
+
# Also blank the nearest index and a couple of neighbors to guarantee a break
|
|
2866
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
2867
|
+
for k in (j - 2, j - 1, j, j + 1, j + 2):
|
|
2868
|
+
if 0 <= k < y.size:
|
|
2869
|
+
y[k] = np.nan
|
|
2870
|
+
except Exception:
|
|
2871
|
+
# Last resort: nearest index only
|
|
2872
|
+
try:
|
|
2873
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
2874
|
+
if 0 <= j < y.size:
|
|
2875
|
+
y[j] = np.nan
|
|
2876
|
+
except Exception:
|
|
2877
|
+
pass
|
|
2878
|
+
# Additionally, break lines across steep jumps or extreme values
|
|
2879
|
+
# Determine a reasonable y-span for thresholds
|
|
2880
|
+
y_span = (
|
|
2881
|
+
abs(ymax - ymin) if (ymax is not None and ymin is not None) else np.nan
|
|
2882
|
+
)
|
|
2883
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
2884
|
+
finite_y = y[np.isfinite(y)]
|
|
2885
|
+
if finite_y.size > 0:
|
|
2886
|
+
y_span = float(np.nanmax(finite_y) - np.nanmin(finite_y))
|
|
2887
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
2888
|
+
y_span = 1.0
|
|
2889
|
+
# Break where adjacent points jump too much relative to span
|
|
2890
|
+
jump_factor = 0.5 # half the axis span signals discontinuity
|
|
2891
|
+
finite_pair = np.isfinite(y[:-1]) & np.isfinite(y[1:])
|
|
2892
|
+
big_jump = finite_pair & (np.abs(y[1:] - y[:-1]) > (jump_factor * y_span))
|
|
2893
|
+
if big_jump.any():
|
|
2894
|
+
idx_break = np.where(big_jump)[0]
|
|
2895
|
+
for i_b in idx_break:
|
|
2896
|
+
if 0 <= i_b + 1 < y.size:
|
|
2897
|
+
y[i_b + 1] = np.nan
|
|
2898
|
+
# Mask values far outside typical range to avoid vertical spikes drawing across
|
|
2899
|
+
mag_factor = 50.0
|
|
2900
|
+
too_big = np.isfinite(y) & (np.abs(y) > (mag_factor * y_span))
|
|
2901
|
+
if too_big.any():
|
|
2902
|
+
y[too_big] = np.nan
|
|
2903
|
+
# Resolve per-function color if provided
|
|
2904
|
+
_col_use = None
|
|
2905
|
+
if isinstance(col_fun, str) and col_fun.strip():
|
|
2906
|
+
try:
|
|
2907
|
+
_col_map = plotmath.COLORS.get(col_fun)
|
|
2908
|
+
except Exception:
|
|
2909
|
+
_col_map = None
|
|
2910
|
+
_col_use = _col_map if _col_map else col_fun
|
|
2911
|
+
|
|
2912
|
+
if lbl:
|
|
2913
|
+
any_label = True
|
|
2914
|
+
ax.plot(
|
|
2915
|
+
x,
|
|
2916
|
+
y,
|
|
2917
|
+
lw=lw,
|
|
2918
|
+
alpha=alpha,
|
|
2919
|
+
label=f"${lbl}$",
|
|
2920
|
+
**({"color": _col_use} if _col_use else {}),
|
|
2921
|
+
)
|
|
2922
|
+
else:
|
|
2923
|
+
ax.plot(
|
|
2924
|
+
x,
|
|
2925
|
+
y,
|
|
2926
|
+
lw=lw,
|
|
2927
|
+
alpha=alpha,
|
|
2928
|
+
**({"color": _col_use} if _col_use else {}),
|
|
2929
|
+
)
|
|
2930
|
+
|
|
2931
|
+
# Draw endpoint markers if enabled and endpoints are specified
|
|
2932
|
+
if endpoint_markers_flag and endpoints:
|
|
2933
|
+
left_type, right_type = endpoints
|
|
2934
|
+
# Get the actual color used for the function
|
|
2935
|
+
color_for_marker = _col_use if _col_use else "black"
|
|
2936
|
+
|
|
2937
|
+
# Draw left endpoint marker (pass full arrays and index)
|
|
2938
|
+
if left_type in ["closed", "open"] and np.isfinite(y[0]):
|
|
2939
|
+
_draw_endpoint_marker(
|
|
2940
|
+
ax, x, y, 0, left_type, "left", color_for_marker, lw
|
|
2941
|
+
)
|
|
2942
|
+
|
|
2943
|
+
# Draw right endpoint marker (pass full arrays and index)
|
|
2944
|
+
if right_type in ["closed", "open"] and np.isfinite(y[-1]):
|
|
2945
|
+
_draw_endpoint_marker(
|
|
2946
|
+
ax, x, y, -1, right_type, "right", color_for_marker, lw
|
|
2947
|
+
)
|
|
2948
|
+
if any_label:
|
|
2949
|
+
ax.legend(fontsize=int(fontsize))
|
|
2950
|
+
|
|
2951
|
+
# Annotations
|
|
2952
|
+
for xytext, xy, text, arc in ann_vals:
|
|
2953
|
+
plotmath.annotate(xy=xy, xytext=xytext, s=text, arc=arc, fontsize=int(fontsize))
|
|
2954
|
+
|
|
2955
|
+
# Lines (y = a*x + b) and tangents; draw before points so markers remain visible
|
|
2956
|
+
if line_vals or tangent_vals:
|
|
2957
|
+
import numpy as _np_l
|
|
2958
|
+
|
|
2959
|
+
style_map_line = {
|
|
2960
|
+
"solid": "-",
|
|
2961
|
+
"dotted": ":",
|
|
2962
|
+
"dashed": "--",
|
|
2963
|
+
"dashdot": "-.",
|
|
2964
|
+
}
|
|
2965
|
+
default_color_line = plotmath.COLORS.get("red")
|
|
2966
|
+
try:
|
|
2967
|
+
from matplotlib import colors as _mcolors
|
|
2968
|
+
except Exception:
|
|
2969
|
+
_mcolors = None
|
|
2970
|
+
x_line = _np_l.array([xmin, xmax], dtype=float)
|
|
2971
|
+
|
|
2972
|
+
def _draw_line(a_l: float, b_l: float, st_l: str | None, col_l: str | None):
|
|
2973
|
+
y_line = a_l * x_line + b_l
|
|
2974
|
+
ls = style_map_line.get((st_l or "dashed").lower(), "--")
|
|
2975
|
+
if col_l:
|
|
2976
|
+
_mapped = plotmath.COLORS.get(col_l)
|
|
2977
|
+
else:
|
|
2978
|
+
_mapped = None
|
|
2979
|
+
col_use = (_mapped if _mapped else col_l) or default_color_line
|
|
2980
|
+
if _mcolors is not None:
|
|
2981
|
+
try:
|
|
2982
|
+
_ = _mcolors.to_rgba(col_use)
|
|
2983
|
+
except Exception:
|
|
2984
|
+
col_use = default_color_line
|
|
2985
|
+
try:
|
|
2986
|
+
ax.plot(
|
|
2987
|
+
x_line,
|
|
2988
|
+
y_line,
|
|
2989
|
+
linestyle=ls,
|
|
2990
|
+
color=col_use,
|
|
2991
|
+
lw=lw,
|
|
2992
|
+
alpha=alpha,
|
|
2993
|
+
)
|
|
2994
|
+
except Exception:
|
|
2995
|
+
ax.plot(
|
|
2996
|
+
x_line,
|
|
2997
|
+
y_line,
|
|
2998
|
+
linestyle=ls,
|
|
2999
|
+
color=default_color_line,
|
|
3000
|
+
lw=lw,
|
|
3001
|
+
alpha=alpha,
|
|
3002
|
+
)
|
|
3003
|
+
|
|
3004
|
+
for a_l, b_l, st_l, col_l in line_vals:
|
|
3005
|
+
_draw_line(a_l, b_l, st_l, col_l)
|
|
3006
|
+
|
|
3007
|
+
# Tangents: allow optional style/color, with dashed red default
|
|
3008
|
+
for a_t, b_t, _x0, st_t, col_t in tangent_vals:
|
|
3009
|
+
style_use = st_t or "dashed"
|
|
3010
|
+
# Prefer the plotmath color palette for the default
|
|
3011
|
+
if col_t:
|
|
3012
|
+
color_use = col_t
|
|
3013
|
+
else:
|
|
3014
|
+
_mapped_red = (
|
|
3015
|
+
plotmath.COLORS.get("red") if hasattr(plotmath, "COLORS") else None
|
|
3016
|
+
)
|
|
3017
|
+
color_use = _mapped_red or "red"
|
|
3018
|
+
_draw_line(a_t, b_t, style_use, color_use)
|
|
3019
|
+
|
|
3020
|
+
# Reset axis limits after drawing lines/tangents to prevent auto-scaling
|
|
3021
|
+
if line_vals or tangent_vals:
|
|
3022
|
+
ax.set_xlim(xmin, xmax)
|
|
3023
|
+
ax.set_ylim(ymin, ymax)
|
|
3024
|
+
|
|
3025
|
+
# Bars
|
|
3026
|
+
for xy, length, orientation in bar_vals:
|
|
3027
|
+
try:
|
|
3028
|
+
# Prefer plotmath.make_bar if available
|
|
3029
|
+
if hasattr(plotmath, "make_bar"):
|
|
3030
|
+
plotmath.make_bar(xy, length, orientation)
|
|
3031
|
+
else:
|
|
3032
|
+
# Fallback: use annotate directly on this axes
|
|
3033
|
+
x, y = xy
|
|
3034
|
+
if orientation == "horizontal":
|
|
3035
|
+
ax.annotate(
|
|
3036
|
+
"",
|
|
3037
|
+
xy=xy,
|
|
3038
|
+
xycoords="data",
|
|
3039
|
+
xytext=(x + length, y),
|
|
3040
|
+
textcoords="data",
|
|
3041
|
+
arrowprops=dict(
|
|
3042
|
+
arrowstyle="|-|,widthA=0.5,widthB=0.5",
|
|
3043
|
+
color="black",
|
|
3044
|
+
),
|
|
3045
|
+
)
|
|
3046
|
+
else:
|
|
3047
|
+
ax.annotate(
|
|
3048
|
+
"",
|
|
3049
|
+
xy=xy,
|
|
3050
|
+
xycoords="data",
|
|
3051
|
+
xytext=(x, y + length),
|
|
3052
|
+
textcoords="data",
|
|
3053
|
+
arrowprops=dict(
|
|
3054
|
+
arrowstyle="|-|,widthA=0.5,widthB=0.5",
|
|
3055
|
+
color="black",
|
|
3056
|
+
),
|
|
3057
|
+
)
|
|
3058
|
+
except Exception:
|
|
3059
|
+
pass
|
|
3060
|
+
|
|
3061
|
+
# Angle arcs
|
|
3062
|
+
if "angle_arcs" in locals() and angle_arcs:
|
|
3063
|
+
try:
|
|
3064
|
+
import numpy as _np_ang
|
|
3065
|
+
except Exception:
|
|
3066
|
+
_np_ang = None
|
|
3067
|
+
if _np_ang is not None:
|
|
3068
|
+
style_map_arc = {
|
|
3069
|
+
"solid": "-",
|
|
3070
|
+
"dotted": ":",
|
|
3071
|
+
"dashed": "--",
|
|
3072
|
+
"dashdot": "-.",
|
|
3073
|
+
}
|
|
3074
|
+
default_arc_color = plotmath.COLORS.get("black") or "black"
|
|
3075
|
+
for cx, cy, r, sa_deg, ea_deg, st_a, col_a in angle_arcs:
|
|
3076
|
+
try:
|
|
3077
|
+
sa = _np_ang.deg2rad(sa_deg)
|
|
3078
|
+
ea = _np_ang.deg2rad(ea_deg)
|
|
3079
|
+
theta = _np_ang.linspace(sa, ea, 1024)
|
|
3080
|
+
xs = cx + r * _np_ang.cos(theta)
|
|
3081
|
+
ys = cy + r * _np_ang.sin(theta)
|
|
3082
|
+
ls_use = style_map_arc.get((st_a or "solid").lower(), "-")
|
|
3083
|
+
# Resolve color via plotmath palette
|
|
3084
|
+
if col_a:
|
|
3085
|
+
_mapped = plotmath.COLORS.get(col_a)
|
|
3086
|
+
else:
|
|
3087
|
+
_mapped = None
|
|
3088
|
+
col_use = (_mapped if _mapped else col_a) or default_arc_color
|
|
3089
|
+
ax.plot(xs, ys, lw=1, color=col_use, linestyle=ls_use)
|
|
3090
|
+
except Exception:
|
|
3091
|
+
pass
|
|
3092
|
+
|
|
3093
|
+
# text with optional positioning and optional bbox
|
|
3094
|
+
|
|
3095
|
+
xmin, xmax = ax.get_xlim()
|
|
3096
|
+
ymin, ymax = ax.get_ylim()
|
|
3097
|
+
ax_dx = xmax - xmin
|
|
3098
|
+
ax_dy = ymax - ymin
|
|
3099
|
+
|
|
3100
|
+
# Determine axes pixel size for consistent visual offsets
|
|
3101
|
+
try:
|
|
3102
|
+
fig.canvas.draw() # ensure layout is realized
|
|
3103
|
+
_bbox_px = ax.get_window_extent()
|
|
3104
|
+
_ax_w_px, _ax_h_px = _bbox_px.width, _bbox_px.height
|
|
3105
|
+
if _ax_w_px <= 0 or _ax_h_px <= 0:
|
|
3106
|
+
_ax_w_px = _ax_h_px = None
|
|
3107
|
+
except Exception:
|
|
3108
|
+
_ax_w_px = _ax_h_px = None
|
|
3109
|
+
|
|
3110
|
+
for x0, y0, text, pos, use_bbox in text_vals:
|
|
3111
|
+
va, ha = _parse_text_positioning(pos)
|
|
3112
|
+
# Factors as fractions of axes size; keep long* ~3.3x larger
|
|
3113
|
+
_fx_short = 0.015
|
|
3114
|
+
_fy_short = 0.015
|
|
3115
|
+
_fx_long = 0.03
|
|
3116
|
+
_fy_long = 0.03
|
|
3117
|
+
|
|
3118
|
+
# Resolve long* into base alignment while keeping larger factors
|
|
3119
|
+
_use_fx = _fx_short
|
|
3120
|
+
_use_fy = _fy_short
|
|
3121
|
+
if va == "longbottom":
|
|
3122
|
+
va = "bottom"
|
|
3123
|
+
_use_fy = _fy_long
|
|
3124
|
+
elif va == "longtop":
|
|
3125
|
+
va = "top"
|
|
3126
|
+
_use_fy = _fy_long
|
|
3127
|
+
if ha == "longright":
|
|
3128
|
+
ha = "right"
|
|
3129
|
+
_use_fx = _fx_long
|
|
3130
|
+
elif ha == "longleft":
|
|
3131
|
+
ha = "left"
|
|
3132
|
+
_use_fx = _fx_long
|
|
3133
|
+
|
|
3134
|
+
if _ax_w_px and _ax_h_px:
|
|
3135
|
+
# Pixel-based offsets converted back to data units
|
|
3136
|
+
dx_px = 0.0
|
|
3137
|
+
dy_px = 0.0
|
|
3138
|
+
if ha == "right":
|
|
3139
|
+
dx_px = -_ax_w_px * _use_fx
|
|
3140
|
+
elif ha == "left":
|
|
3141
|
+
dx_px = _ax_w_px * _use_fx
|
|
3142
|
+
if va == "bottom":
|
|
3143
|
+
dy_px = _ax_h_px * _use_fy
|
|
3144
|
+
elif va == "top":
|
|
3145
|
+
dy_px = -_ax_h_px * _use_fy
|
|
3146
|
+
x_disp, y_disp = ax.transData.transform((x0, y0))
|
|
3147
|
+
x1, y1 = ax.transData.inverted().transform((x_disp + dx_px, y_disp + dy_px))
|
|
3148
|
+
dx = x1 - x0
|
|
3149
|
+
dy = y1 - y0
|
|
3150
|
+
else:
|
|
3151
|
+
# Fallback to fractions of data span
|
|
3152
|
+
if va == "bottom":
|
|
3153
|
+
dy = _fy_short * ax_dy if _use_fy == _fy_short else _fy_long * ax_dy
|
|
3154
|
+
elif va == "top":
|
|
3155
|
+
dy = -(_fy_short * ax_dy if _use_fy == _fy_short else _fy_long * ax_dy)
|
|
3156
|
+
else:
|
|
3157
|
+
dy = 0.0
|
|
3158
|
+
if ha == "right":
|
|
3159
|
+
dx = -(_fx_short * ax_dx if _use_fx == _fx_short else _fx_long * ax_dx)
|
|
3160
|
+
elif ha == "left":
|
|
3161
|
+
dx = _fx_short * ax_dx if _use_fx == _fx_short else _fx_long * ax_dx
|
|
3162
|
+
else:
|
|
3163
|
+
dx = 0.0
|
|
3164
|
+
|
|
3165
|
+
bbox_kwargs = (
|
|
3166
|
+
dict(
|
|
3167
|
+
boxstyle="round,pad=0.4",
|
|
3168
|
+
fc="white",
|
|
3169
|
+
ec="black",
|
|
3170
|
+
lw=1.5,
|
|
3171
|
+
alpha=0.7,
|
|
3172
|
+
)
|
|
3173
|
+
if use_bbox
|
|
3174
|
+
else None
|
|
3175
|
+
)
|
|
3176
|
+
|
|
3177
|
+
if bbox_kwargs:
|
|
3178
|
+
ax.text(
|
|
3179
|
+
x0 + 1.5 * dx,
|
|
3180
|
+
y0 + 1.5 * dy,
|
|
3181
|
+
text,
|
|
3182
|
+
fontsize=int(fontsize),
|
|
3183
|
+
ha=ha,
|
|
3184
|
+
va=va,
|
|
3185
|
+
bbox=bbox_kwargs,
|
|
3186
|
+
)
|
|
3187
|
+
else:
|
|
3188
|
+
ax.text(x0 + dx, y0 + dy, text, fontsize=int(fontsize), ha=ha, va=va)
|
|
3189
|
+
|
|
3190
|
+
# line segments (draw before vlines/hlines so guides overlay if needed)
|
|
3191
|
+
if "line_segment_vals" in locals() and line_segment_vals:
|
|
3192
|
+
style_map_seg = {
|
|
3193
|
+
"solid": "-",
|
|
3194
|
+
"dotted": ":",
|
|
3195
|
+
"dashed": "--",
|
|
3196
|
+
"dashdot": "-.",
|
|
3197
|
+
}
|
|
3198
|
+
default_seg_color = plotmath.COLORS.get("red")
|
|
3199
|
+
try:
|
|
3200
|
+
from matplotlib import colors as _mcolors_seg
|
|
3201
|
+
except Exception:
|
|
3202
|
+
_mcolors_seg = None
|
|
3203
|
+
for p1, p2, st_seg, col_seg in line_segment_vals:
|
|
3204
|
+
(x1s, y1s), (x2s, y2s) = p1, p2
|
|
3205
|
+
ls_use = style_map_seg.get((st_seg or "solid").lower(), "-")
|
|
3206
|
+
if col_seg:
|
|
3207
|
+
_mapped_seg = plotmath.COLORS.get(col_seg)
|
|
3208
|
+
else:
|
|
3209
|
+
_mapped_seg = None
|
|
3210
|
+
col_use = (_mapped_seg if _mapped_seg else col_seg) or default_seg_color
|
|
3211
|
+
if _mcolors_seg is not None:
|
|
3212
|
+
try:
|
|
3213
|
+
_ = _mcolors_seg.to_rgba(col_use)
|
|
3214
|
+
except Exception:
|
|
3215
|
+
col_use = default_seg_color
|
|
3216
|
+
try:
|
|
3217
|
+
ax.plot(
|
|
3218
|
+
[x1s, x2s],
|
|
3219
|
+
[y1s, y2s],
|
|
3220
|
+
linestyle=ls_use,
|
|
3221
|
+
color=col_use,
|
|
3222
|
+
lw=lw,
|
|
3223
|
+
)
|
|
3224
|
+
except Exception:
|
|
3225
|
+
pass
|
|
3226
|
+
# Circles
|
|
3227
|
+
if "circle_vals" in locals() and circle_vals:
|
|
3228
|
+
try:
|
|
3229
|
+
from matplotlib import patches as _mpatches_c
|
|
3230
|
+
except Exception:
|
|
3231
|
+
_mpatches_c = None
|
|
3232
|
+
if _mpatches_c is not None:
|
|
3233
|
+
style_map_circle = {
|
|
3234
|
+
"solid": "-",
|
|
3235
|
+
"dotted": ":",
|
|
3236
|
+
"dashed": "--",
|
|
3237
|
+
"dashdot": "-.",
|
|
3238
|
+
}
|
|
3239
|
+
default_circle_color = plotmath.COLORS.get("black") or "black"
|
|
3240
|
+
for cx, cy, r_c, st_c, col_c in circle_vals:
|
|
3241
|
+
try:
|
|
3242
|
+
# Resolve color
|
|
3243
|
+
if col_c:
|
|
3244
|
+
mapped = plotmath.COLORS.get(col_c)
|
|
3245
|
+
else:
|
|
3246
|
+
mapped = None
|
|
3247
|
+
col_use = (mapped if mapped else col_c) or default_circle_color
|
|
3248
|
+
# Resolve linestyle -> we pass as linestyle on patch edge
|
|
3249
|
+
ls_use = style_map_circle.get((st_c or "solid").lower(), "-")
|
|
3250
|
+
circ = _mpatches_c.Circle(
|
|
3251
|
+
(cx, cy),
|
|
3252
|
+
r_c,
|
|
3253
|
+
fill=False,
|
|
3254
|
+
edgecolor=col_use,
|
|
3255
|
+
facecolor="none",
|
|
3256
|
+
linestyle=ls_use,
|
|
3257
|
+
lw=lw,
|
|
3258
|
+
)
|
|
3259
|
+
ax.add_patch(circ)
|
|
3260
|
+
except Exception:
|
|
3261
|
+
pass
|
|
3262
|
+
# Ellipses
|
|
3263
|
+
if "ellipse_vals" in locals() and ellipse_vals:
|
|
3264
|
+
try:
|
|
3265
|
+
import numpy as _np_el
|
|
3266
|
+
except Exception:
|
|
3267
|
+
_np_el = None
|
|
3268
|
+
if _np_el is not None:
|
|
3269
|
+
style_map_ellipse = {
|
|
3270
|
+
"solid": "-",
|
|
3271
|
+
"dotted": ":",
|
|
3272
|
+
"dashed": "--",
|
|
3273
|
+
"dashdot": "-.",
|
|
3274
|
+
}
|
|
3275
|
+
default_ellipse_color = plotmath.COLORS.get("black") or "black"
|
|
3276
|
+
for x0e, y0e, a_e, b_e, st_e, col_e in ellipse_vals:
|
|
3277
|
+
try:
|
|
3278
|
+
t = _np_el.linspace(0, 2 * _np_el.pi, 1024)
|
|
3279
|
+
xs = x0e + a_e * _np_el.cos(t)
|
|
3280
|
+
ys = y0e + b_e * _np_el.sin(t)
|
|
3281
|
+
if col_e:
|
|
3282
|
+
mapped = plotmath.COLORS.get(col_e)
|
|
3283
|
+
else:
|
|
3284
|
+
mapped = None
|
|
3285
|
+
col_use = (mapped if mapped else col_e) or default_ellipse_color
|
|
3286
|
+
ls_use = style_map_ellipse.get((st_e or "solid").lower(), "-")
|
|
3287
|
+
ax.plot(xs, ys, color=col_use, linestyle=ls_use, lw=lw)
|
|
3288
|
+
except Exception:
|
|
3289
|
+
pass
|
|
3290
|
+
|
|
3291
|
+
# Curves (parametric x(t), y(t))
|
|
3292
|
+
if "curve_specs" in locals() and curve_specs:
|
|
3293
|
+
try:
|
|
3294
|
+
import sympy as _sp_curve
|
|
3295
|
+
import numpy as _np_curve
|
|
3296
|
+
except Exception:
|
|
3297
|
+
_sp_curve = None
|
|
3298
|
+
_np_curve = None
|
|
3299
|
+
if _sp_curve is not None and _np_curve is not None:
|
|
3300
|
+
style_map_curve = {
|
|
3301
|
+
"solid": "-",
|
|
3302
|
+
"dotted": ":",
|
|
3303
|
+
"dashed": "--",
|
|
3304
|
+
"dashdot": "-.",
|
|
3305
|
+
}
|
|
3306
|
+
default_curve_color = plotmath.COLORS.get("black") or "black"
|
|
3307
|
+
for x_expr_s, y_expr_s, t0_c, t1_c, st_c, col_c in curve_specs:
|
|
3308
|
+
try:
|
|
3309
|
+
t_sym = _sp_curve.symbols("t")
|
|
3310
|
+
# Sympify with local symbol t; rely on SymPy's safe parsing (no arbitrary exec)
|
|
3311
|
+
x_sym = _sp_curve.sympify(x_expr_s, locals={"t": t_sym})
|
|
3312
|
+
y_sym = _sp_curve.sympify(y_expr_s, locals={"t": t_sym})
|
|
3313
|
+
fx = _sp_curve.lambdify(t_sym, x_sym, "numpy")
|
|
3314
|
+
fy = _sp_curve.lambdify(t_sym, y_sym, "numpy")
|
|
3315
|
+
t_arr = _np_curve.linspace(t0_c, t1_c, 1024)
|
|
3316
|
+
xs = fx(t_arr)
|
|
3317
|
+
ys = fy(t_arr)
|
|
3318
|
+
# Basic sanity checks
|
|
3319
|
+
try:
|
|
3320
|
+
_ = len(xs)
|
|
3321
|
+
_ = len(ys)
|
|
3322
|
+
except Exception:
|
|
3323
|
+
continue
|
|
3324
|
+
mapped = plotmath.COLORS.get(col_c) if col_c else None
|
|
3325
|
+
col_use = (mapped if mapped else col_c) or default_curve_color
|
|
3326
|
+
ls_use = style_map_curve.get((st_c or "solid").lower(), "-")
|
|
3327
|
+
ax.plot(xs, ys, color=col_use, linestyle=ls_use, lw=lw)
|
|
3328
|
+
except Exception:
|
|
3329
|
+
continue
|
|
3330
|
+
|
|
3331
|
+
# vlines
|
|
3332
|
+
style_map = {
|
|
3333
|
+
"solid": "-",
|
|
3334
|
+
"dotted": ":",
|
|
3335
|
+
"dashed": "--",
|
|
3336
|
+
"dashdot": "-.",
|
|
3337
|
+
}
|
|
3338
|
+
default_color = plotmath.COLORS.get("red")
|
|
3339
|
+
for x_v, y0, y1, st, col in vline_vals:
|
|
3340
|
+
y_min = ymin if y0 is None else y0
|
|
3341
|
+
y_max = ymax if y1 is None else y1
|
|
3342
|
+
ls_val = style_map.get((st or "dashed").lower(), ":")
|
|
3343
|
+
# Resolve user color through plotmath.COLORS, then fallback to original, then default
|
|
3344
|
+
_mapped = plotmath.COLORS.get(col) if col else None
|
|
3345
|
+
color_to_try = (_mapped if _mapped else col) or default_color
|
|
3346
|
+
try:
|
|
3347
|
+
ax.vlines(
|
|
3348
|
+
x=x_v,
|
|
3349
|
+
ymin=y_min,
|
|
3350
|
+
ymax=y_max,
|
|
3351
|
+
colors=color_to_try,
|
|
3352
|
+
lw=lw,
|
|
3353
|
+
alpha=1,
|
|
3354
|
+
ls=ls_val,
|
|
3355
|
+
)
|
|
3356
|
+
except Exception:
|
|
3357
|
+
ax.vlines(
|
|
3358
|
+
x=x_v,
|
|
3359
|
+
ymin=y_min,
|
|
3360
|
+
ymax=y_max,
|
|
3361
|
+
colors=default_color,
|
|
3362
|
+
lw=lw,
|
|
3363
|
+
alpha=1,
|
|
3364
|
+
ls=ls_val,
|
|
3365
|
+
)
|
|
3366
|
+
|
|
3367
|
+
# hlines
|
|
3368
|
+
for y_h, x0, x1, st_h, col_h in hline_vals:
|
|
3369
|
+
x_min = xmin if x0 is None else x0
|
|
3370
|
+
x_max = xmax if x1 is None else x1
|
|
3371
|
+
ls_val_h = style_map.get((st_h or "dashed").lower(), ":")
|
|
3372
|
+
# Resolve user color through plotmath.COLORS, then fallback to original, then default
|
|
3373
|
+
_mapped_h = plotmath.COLORS.get(col_h) if col_h else None
|
|
3374
|
+
color_to_try_h = (_mapped_h if _mapped_h else col_h) or default_color
|
|
3375
|
+
try:
|
|
3376
|
+
ax.hlines(
|
|
3377
|
+
y=y_h,
|
|
3378
|
+
xmin=x_min,
|
|
3379
|
+
xmax=x_max,
|
|
3380
|
+
colors=color_to_try_h,
|
|
3381
|
+
lw=lw,
|
|
3382
|
+
alpha=1,
|
|
3383
|
+
ls=ls_val_h,
|
|
3384
|
+
)
|
|
3385
|
+
except Exception:
|
|
3386
|
+
ax.hlines(
|
|
3387
|
+
y=y_h,
|
|
3388
|
+
xmin=x_min,
|
|
3389
|
+
xmax=x_max,
|
|
3390
|
+
colors=default_color,
|
|
3391
|
+
lw=lw,
|
|
3392
|
+
alpha=1,
|
|
3393
|
+
ls=ls_val_h,
|
|
3394
|
+
)
|
|
3395
|
+
|
|
3396
|
+
# polygons
|
|
3397
|
+
for pts, show, poly_color, poly_alpha in poly_vals:
|
|
3398
|
+
kwargs = {"show_vertices": True} if show else {}
|
|
3399
|
+
|
|
3400
|
+
# If color is provided, use it with the specified alpha (or default 0.1)
|
|
3401
|
+
# If no color is provided, set alpha=0 so the interior is not filled
|
|
3402
|
+
if poly_color:
|
|
3403
|
+
# Resolve color through plotmath.COLORS, then fallback to original
|
|
3404
|
+
_mapped_poly = plotmath.COLORS.get(poly_color)
|
|
3405
|
+
resolved_color = _mapped_poly if _mapped_poly else poly_color
|
|
3406
|
+
kwargs["color"] = resolved_color
|
|
3407
|
+
kwargs["alpha"] = poly_alpha if poly_alpha is not None else 0.3
|
|
3408
|
+
else:
|
|
3409
|
+
kwargs["alpha"] = 0
|
|
3410
|
+
|
|
3411
|
+
try:
|
|
3412
|
+
plotmath.polygon(*pts, **kwargs)
|
|
3413
|
+
except Exception:
|
|
3414
|
+
# ignore to avoid breaking the build on a single bad polygon
|
|
3415
|
+
pass
|
|
3416
|
+
|
|
3417
|
+
# filled polygons
|
|
3418
|
+
default_fill_color = plotmath.COLORS.get("blue")
|
|
3419
|
+
for pts, color_fp, alpha_fp in poly_fill_vals:
|
|
3420
|
+
# Resolve user color through plotmath.COLORS, then fallback to original, then default
|
|
3421
|
+
if color_fp:
|
|
3422
|
+
_mapped_fp = plotmath.COLORS.get(color_fp)
|
|
3423
|
+
else:
|
|
3424
|
+
_mapped_fp = None
|
|
3425
|
+
c = (_mapped_fp if _mapped_fp else color_fp) or default_fill_color
|
|
3426
|
+
a = 0.1 if alpha_fp is None else alpha_fp
|
|
3427
|
+
try:
|
|
3428
|
+
plotmath.polygon(*pts, edges=False, color=c, alpha=a)
|
|
3429
|
+
except Exception:
|
|
3430
|
+
try:
|
|
3431
|
+
plotmath.polygon(*pts, edges=False, facecolor=c, alpha=a)
|
|
3432
|
+
except Exception:
|
|
3433
|
+
plotmath.polygon(*pts, edges=False, alpha=a)
|
|
3434
|
+
|
|
3435
|
+
# Plot points
|
|
3436
|
+
for x0, y0 in point_vals:
|
|
3437
|
+
ax.plot(x0, y0, "o", markersize=10, alpha=0.8, color="black")
|
|
3438
|
+
|
|
3439
|
+
# Vectors (quiver) drawn last so they appear on top of all other elements
|
|
3440
|
+
# Made scale-invariant and angle-invariant by working in pixel space
|
|
3441
|
+
if vector_vals:
|
|
3442
|
+
import numpy as np_vec
|
|
3443
|
+
|
|
3444
|
+
default_vector_color = plotmath.COLORS.get("black") or "black"
|
|
3445
|
+
|
|
3446
|
+
# Get axes limits and pixel dimensions for invariant scaling
|
|
3447
|
+
xlim_vec = ax.get_xlim()
|
|
3448
|
+
ylim_vec = ax.get_ylim()
|
|
3449
|
+
x_range_vec = xlim_vec[1] - xlim_vec[0]
|
|
3450
|
+
y_range_vec = ylim_vec[1] - ylim_vec[0]
|
|
3451
|
+
|
|
3452
|
+
try:
|
|
3453
|
+
bbox_vec = ax.get_window_extent()
|
|
3454
|
+
ax_width_px_vec = bbox_vec.width
|
|
3455
|
+
ax_height_px_vec = bbox_vec.height
|
|
3456
|
+
except Exception:
|
|
3457
|
+
ax_width_px_vec = 600
|
|
3458
|
+
ax_height_px_vec = 600
|
|
3459
|
+
|
|
3460
|
+
# Conversion factors between data and pixel coordinates
|
|
3461
|
+
data_to_px_x_vec = ax_width_px_vec / x_range_vec if x_range_vec > 0 else 1.0
|
|
3462
|
+
data_to_px_y_vec = ax_height_px_vec / y_range_vec if y_range_vec > 0 else 1.0
|
|
3463
|
+
px_to_data_x_vec = (
|
|
3464
|
+
x_range_vec / ax_width_px_vec if ax_width_px_vec > 0 else 0.01
|
|
3465
|
+
)
|
|
3466
|
+
px_to_data_y_vec = (
|
|
3467
|
+
y_range_vec / ax_height_px_vec if ax_height_px_vec > 0 else 0.01
|
|
3468
|
+
)
|
|
3469
|
+
|
|
3470
|
+
try:
|
|
3471
|
+
for x_v, y_v, dx_v, dy_v, col_v in vector_vals:
|
|
3472
|
+
# Convert vector components from data to pixel space
|
|
3473
|
+
# This preserves angles visually on screen
|
|
3474
|
+
dx_px = dx_v * data_to_px_x_vec
|
|
3475
|
+
dy_px = dy_v * data_to_px_y_vec
|
|
3476
|
+
|
|
3477
|
+
# Get vector length in pixel space
|
|
3478
|
+
vec_length_px = np_vec.sqrt(dx_px**2 + dy_px**2)
|
|
3479
|
+
|
|
3480
|
+
if vec_length_px > 1e-10:
|
|
3481
|
+
# Normalize in pixel space to preserve direction
|
|
3482
|
+
dx_px_norm = dx_px / vec_length_px
|
|
3483
|
+
dy_px_norm = dy_px / vec_length_px
|
|
3484
|
+
|
|
3485
|
+
# Use the original pixel length (scale-invariant)
|
|
3486
|
+
# This keeps the arrow at the size specified by the user
|
|
3487
|
+
dx_px_scaled = dx_px_norm * vec_length_px
|
|
3488
|
+
dy_px_scaled = dy_px_norm * vec_length_px
|
|
3489
|
+
|
|
3490
|
+
# Convert back to data coordinates
|
|
3491
|
+
dx_data = dx_px_scaled * px_to_data_x_vec
|
|
3492
|
+
dy_data = dy_px_scaled * px_to_data_y_vec
|
|
3493
|
+
else:
|
|
3494
|
+
# Zero or near-zero vector
|
|
3495
|
+
dx_data = 0
|
|
3496
|
+
dy_data = 0
|
|
3497
|
+
|
|
3498
|
+
# Resolve color through palette first
|
|
3499
|
+
if col_v:
|
|
3500
|
+
_mapped_vec = plotmath.COLORS.get(col_v)
|
|
3501
|
+
else:
|
|
3502
|
+
_mapped_vec = None
|
|
3503
|
+
color_use = (
|
|
3504
|
+
_mapped_vec if _mapped_vec else col_v
|
|
3505
|
+
) or default_vector_color
|
|
3506
|
+
|
|
3507
|
+
# Draw arrow with scale=1 and scale_units='xy' so our data coordinates are used directly
|
|
3508
|
+
ax.quiver(
|
|
3509
|
+
x_v,
|
|
3510
|
+
y_v,
|
|
3511
|
+
dx_data,
|
|
3512
|
+
dy_data,
|
|
3513
|
+
angles="xy",
|
|
3514
|
+
scale_units="xy",
|
|
3515
|
+
scale=1,
|
|
3516
|
+
width=0.006,
|
|
3517
|
+
headwidth=5,
|
|
3518
|
+
headlength=5,
|
|
3519
|
+
color=color_use,
|
|
3520
|
+
zorder=100, # High zorder to ensure vectors are always on top
|
|
3521
|
+
)
|
|
3522
|
+
except Exception:
|
|
3523
|
+
pass
|
|
3524
|
+
|
|
3525
|
+
# axis commands (run sequentially) — retain for legacy commands; we
|
|
3526
|
+
# skip reapplying 'off'/'equal' earlier logic is already applied but
|
|
3527
|
+
# they are harmless if repeated.
|
|
3528
|
+
for cmd in axis_cmds:
|
|
3529
|
+
try:
|
|
3530
|
+
ax.axis(cmd)
|
|
3531
|
+
except Exception:
|
|
3532
|
+
pass
|
|
3533
|
+
|
|
3534
|
+
# Axis labels: allow optional labelpad via "label, pad"
|
|
3535
|
+
def _split_label_and_pad(val: Any) -> tuple[str | None, float | None]:
|
|
3536
|
+
if not isinstance(val, str):
|
|
3537
|
+
return None, None
|
|
3538
|
+
s = val.strip()
|
|
3539
|
+
if not s:
|
|
3540
|
+
return None, None
|
|
3541
|
+
# Try literal form [label, pad] or (label, pad)
|
|
3542
|
+
lit = _safe_literal(s)
|
|
3543
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 1:
|
|
3544
|
+
label = str(lit[0]).strip()
|
|
3545
|
+
pad: float | None = None
|
|
3546
|
+
if len(lit) >= 2:
|
|
3547
|
+
try:
|
|
3548
|
+
pad = float(lit[1])
|
|
3549
|
+
except Exception:
|
|
3550
|
+
pad = None
|
|
3551
|
+
return (label if label else None), pad
|
|
3552
|
+
# CSV fallback: split on last comma so labels with commas still work when quoted
|
|
3553
|
+
parts = [p.strip() for p in s.split(",")]
|
|
3554
|
+
if len(parts) >= 2:
|
|
3555
|
+
try:
|
|
3556
|
+
pad = float(parts[-1])
|
|
3557
|
+
label = ",".join(parts[:-1]).strip()
|
|
3558
|
+
return (label if label else None), pad
|
|
3559
|
+
except Exception:
|
|
3560
|
+
pass
|
|
3561
|
+
return (s if s else None), None
|
|
3562
|
+
|
|
3563
|
+
xl_raw = merged.get("xlabel")
|
|
3564
|
+
yl_raw = merged.get("ylabel")
|
|
3565
|
+
xl_text, xl_pad = _split_label_and_pad(xl_raw)
|
|
3566
|
+
yl_text, yl_pad = _split_label_and_pad(yl_raw)
|
|
3567
|
+
|
|
3568
|
+
if isinstance(yl_text, str) and yl_text.strip():
|
|
3569
|
+
try:
|
|
3570
|
+
kwargs = dict(fontsize=int(fontsize), loc="top", rotation="horizontal")
|
|
3571
|
+
if yl_pad is not None:
|
|
3572
|
+
kwargs["labelpad"] = yl_pad
|
|
3573
|
+
ax.set_ylabel(yl_text, **kwargs)
|
|
3574
|
+
except Exception:
|
|
3575
|
+
ax.set_ylabel(yl_text, fontsize=int(fontsize))
|
|
3576
|
+
if isinstance(xl_text, str) and xl_text.strip():
|
|
3577
|
+
try:
|
|
3578
|
+
kwargs = dict(fontsize=int(fontsize), loc="right")
|
|
3579
|
+
if xl_pad is not None:
|
|
3580
|
+
kwargs["labelpad"] = xl_pad
|
|
3581
|
+
ax.set_xlabel(xl_text, **kwargs)
|
|
3582
|
+
except Exception:
|
|
3583
|
+
ax.set_xlabel(xl_text, fontsize=int(fontsize))
|
|
3584
|
+
|
|
3585
|
+
# Apply user figsize at the very end if provided
|
|
3586
|
+
if parsed_figsize is not None:
|
|
3587
|
+
try:
|
|
3588
|
+
fig.set_size_inches(*parsed_figsize)
|
|
3589
|
+
except Exception:
|
|
3590
|
+
pass
|
|
3591
|
+
|
|
3592
|
+
# Handle individual tick control (xticks/yticks off)
|
|
3593
|
+
xticks_raw = merged.get("xticks")
|
|
3594
|
+
yticks_raw = merged.get("yticks")
|
|
3595
|
+
|
|
3596
|
+
if isinstance(xticks_raw, str) and xticks_raw.strip().lower() == "off":
|
|
3597
|
+
try:
|
|
3598
|
+
ax.set_xticks([])
|
|
3599
|
+
except Exception:
|
|
3600
|
+
pass
|
|
3601
|
+
|
|
3602
|
+
if isinstance(yticks_raw, str) and yticks_raw.strip().lower() == "off":
|
|
3603
|
+
try:
|
|
3604
|
+
ax.set_yticks([])
|
|
3605
|
+
except Exception:
|
|
3606
|
+
pass
|
|
3607
|
+
|
|
3608
|
+
# Apply tight_layout to prevent label clipping
|
|
3609
|
+
try:
|
|
3610
|
+
# Make sure text extents are realized before layout when using TeX
|
|
3611
|
+
try:
|
|
3612
|
+
fig.canvas.draw()
|
|
3613
|
+
except Exception:
|
|
3614
|
+
pass
|
|
3615
|
+
fig.tight_layout()
|
|
3616
|
+
except Exception:
|
|
3617
|
+
pass
|
|
3618
|
+
|
|
3619
|
+
fig.savefig(
|
|
3620
|
+
abs_svg,
|
|
3621
|
+
format="svg",
|
|
3622
|
+
transparent=True,
|
|
3623
|
+
)
|
|
3624
|
+
if debug_mode:
|
|
3625
|
+
# Sidecar PDF (optional for debugging)
|
|
3626
|
+
try:
|
|
3627
|
+
fig.savefig(
|
|
3628
|
+
os.path.join(abs_dir, f"{base_name}.pdf"),
|
|
3629
|
+
format="pdf",
|
|
3630
|
+
transparent=True,
|
|
3631
|
+
)
|
|
3632
|
+
except Exception:
|
|
3633
|
+
pass
|
|
3634
|
+
|
|
3635
|
+
matplotlib.pyplot.close(fig)
|
|
3636
|
+
# Restore rcParams modified for this figure
|
|
3637
|
+
try:
|
|
3638
|
+
matplotlib.rcParams["text.usetex"] = _old_usetex
|
|
3639
|
+
matplotlib.rcParams["mathtext.fontset"] = _old_mathtext
|
|
3640
|
+
except Exception:
|
|
3641
|
+
pass
|
|
3642
|
+
except Exception as e:
|
|
3643
|
+
# Best-effort rcParams restoration on error
|
|
3644
|
+
try:
|
|
3645
|
+
matplotlib.rcParams["text.usetex"] = _old_usetex
|
|
3646
|
+
matplotlib.rcParams["mathtext.fontset"] = _old_mathtext
|
|
3647
|
+
except Exception:
|
|
3648
|
+
pass
|
|
3649
|
+
return [
|
|
3650
|
+
self.state_machine.reporter.error(
|
|
3651
|
+
f"Feil under generering av figur: {e}", line=self.lineno
|
|
3652
|
+
)
|
|
3653
|
+
]
|
|
3654
|
+
|
|
3655
|
+
if not os.path.exists(abs_svg):
|
|
3656
|
+
return [self.state_machine.reporter.error("plot: SVG mangler.", line=self.lineno)]
|
|
3657
|
+
|
|
3658
|
+
env.note_dependency(abs_svg)
|
|
3659
|
+
# copy into build _static
|
|
3660
|
+
try:
|
|
3661
|
+
out_static = os.path.join(app.outdir, "_static", "plot")
|
|
3662
|
+
os.makedirs(out_static, exist_ok=True)
|
|
3663
|
+
shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
|
|
3664
|
+
except Exception:
|
|
3665
|
+
pass
|
|
3666
|
+
|
|
3667
|
+
try:
|
|
3668
|
+
raw_svg = open(abs_svg, "r", encoding="utf-8").read()
|
|
3669
|
+
except Exception as e:
|
|
3670
|
+
return [
|
|
3671
|
+
self.state_machine.reporter.error(
|
|
3672
|
+
f"plot inline: kunne ikke lese SVG: {e}", line=self.lineno
|
|
3673
|
+
)
|
|
3674
|
+
]
|
|
3675
|
+
|
|
3676
|
+
if not debug_mode and "viewBox" in raw_svg:
|
|
3677
|
+
raw_svg = _strip_root_svg_size(raw_svg)
|
|
3678
|
+
|
|
3679
|
+
if not debug_mode:
|
|
3680
|
+
raw_svg = _rewrite_ids(raw_svg, f"cpl_{content_hash}_{uuid.uuid4().hex[:6]}_")
|
|
3681
|
+
|
|
3682
|
+
alt_default = "Tilpasset figur"
|
|
3683
|
+
alt = merged.get("alt", alt_default)
|
|
3684
|
+
|
|
3685
|
+
width_opt = merged.get("width")
|
|
3686
|
+
percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
|
|
3687
|
+
|
|
3688
|
+
def _augment(m):
|
|
3689
|
+
tag = m.group(0)
|
|
3690
|
+
if "class=" not in tag:
|
|
3691
|
+
tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
|
|
3692
|
+
else:
|
|
3693
|
+
tag = tag.replace('class="', 'class="graph-inline-svg ')
|
|
3694
|
+
if alt and "aria-label=" not in tag:
|
|
3695
|
+
tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
|
|
3696
|
+
if width_opt:
|
|
3697
|
+
if percent:
|
|
3698
|
+
wval = width_opt.strip()
|
|
3699
|
+
else:
|
|
3700
|
+
wval = width_opt.strip()
|
|
3701
|
+
if wval.isdigit():
|
|
3702
|
+
wval += "px"
|
|
3703
|
+
style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
|
|
3704
|
+
if "style=" in tag:
|
|
3705
|
+
tag = re.sub(
|
|
3706
|
+
r'style="([^"]*)"',
|
|
3707
|
+
lambda mm: f'style="{mm.group(1)}; {style_frag}"',
|
|
3708
|
+
tag,
|
|
3709
|
+
count=1,
|
|
3710
|
+
)
|
|
3711
|
+
else:
|
|
3712
|
+
tag = tag[:-1] + f' style="{style_frag}"' + ">"
|
|
3713
|
+
return tag
|
|
3714
|
+
|
|
3715
|
+
raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
|
|
3716
|
+
# Deliberately do not inject a <title> element: browsers display it as a tooltip
|
|
3717
|
+
# on hover which is distracting for readers. Accessibility is still ensured via
|
|
3718
|
+
# role="img" and aria-label attributes already added in _augment(). If a title
|
|
3719
|
+
# is ever desired for a specific figure, that can be added manually after build
|
|
3720
|
+
# or a future directive option could re-enable this behavior.
|
|
3721
|
+
|
|
3722
|
+
figure = nodes.figure()
|
|
3723
|
+
figure.setdefault("classes", []).extend(["adaptive-figure", "plot-figure", "no-click"])
|
|
3724
|
+
raw_node = nodes.raw("", raw_svg, format="html")
|
|
3725
|
+
raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
|
|
3726
|
+
figure += raw_node
|
|
3727
|
+
|
|
3728
|
+
extra_classes = merged.get("class")
|
|
3729
|
+
if extra_classes:
|
|
3730
|
+
figure["classes"].extend(extra_classes)
|
|
3731
|
+
figure["align"] = merged.get("align", "center")
|
|
3732
|
+
|
|
3733
|
+
caption_lines = list(self.content)[caption_idx:]
|
|
3734
|
+
while caption_lines and not caption_lines[0].strip():
|
|
3735
|
+
caption_lines.pop(0)
|
|
3736
|
+
if caption_lines:
|
|
3737
|
+
caption = nodes.caption()
|
|
3738
|
+
caption_text = "\n".join(caption_lines)
|
|
3739
|
+
# Parse as inline text to support math while avoiding extra paragraph nodes
|
|
3740
|
+
parsed_nodes, messages = self.state.inline_text(caption_text, self.lineno)
|
|
3741
|
+
caption.extend(parsed_nodes)
|
|
3742
|
+
figure += caption
|
|
3743
|
+
|
|
3744
|
+
if explicit_name:
|
|
3745
|
+
self.add_name(figure)
|
|
3746
|
+
return [figure]
|
|
3747
|
+
|
|
3748
|
+
|
|
3749
|
+
def setup(app): # pragma: no cover
|
|
3750
|
+
app.add_directive("plot", PlotDirective)
|
|
3751
|
+
# Ensure our figure CSS is linked even if the root package setup did not run first
|
|
3752
|
+
try:
|
|
3753
|
+
app.add_css_file("munchboka/css/general_style.css")
|
|
3754
|
+
except Exception:
|
|
3755
|
+
pass
|
|
3756
|
+
# Global default for LaTeX usage in plots; can be overridden per-figure via 'usetex:'
|
|
3757
|
+
app.add_config_value("plot_default_usetex", True, "env")
|
|
3758
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|