munchboka-edutools 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- munchboka_edutools/__init__.py +182 -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 +356 -0
- munchboka_edutools/directives/cas_popup.py +272 -0
- munchboka_edutools/directives/dialogue.py +137 -0
- munchboka_edutools/directives/escape_room.py +296 -0
- munchboka_edutools/directives/factor_tree.py +549 -0
- munchboka_edutools/directives/ggb.py +209 -0
- munchboka_edutools/directives/ggb_icon.py +62 -0
- munchboka_edutools/directives/ggb_popup.py +165 -0
- munchboka_edutools/directives/horner.py +324 -0
- munchboka_edutools/directives/interactive_code.py +75 -0
- munchboka_edutools/directives/jeopardy.py +252 -0
- munchboka_edutools/directives/multi_plot.py +1126 -0
- munchboka_edutools/directives/pair_puzzle.py +191 -0
- munchboka_edutools/directives/parsons.py +109 -0
- munchboka_edutools/directives/plot.py +3012 -0
- munchboka_edutools/directives/poly_icon.py +91 -0
- munchboka_edutools/directives/polydiv.py +344 -0
- munchboka_edutools/directives/quiz.py +291 -0
- munchboka_edutools/directives/signchart.py +474 -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 +274 -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 +112 -0
- munchboka_edutools/static/css/github-light.css +120 -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 +476 -0
- munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
- munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
- munchboka_edutools/static/css/quiz.css +312 -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/geogebra-setup.js +6 -0
- munchboka_edutools/static/js/highlight-init.js +6 -0
- munchboka_edutools/static/js/interactiveCode/codeEditor.js +632 -0
- munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +348 -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/interactive_code/codeEditor.js +662 -0
- munchboka_edutools/static/js/interactive_code/interactiveCodeSetup.js +252 -0
- munchboka_edutools/static/js/interactive_code/pythonRunner.js +145 -0
- munchboka_edutools/static/js/interactive_code/turtleCode.js +56 -0
- munchboka_edutools/static/js/interactive_code/workerManager.js +204 -0
- munchboka_edutools/static/js/jeopardy.js +457 -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/quiz.js +422 -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.1.0.dist-info/METADATA +107 -0
- munchboka_edutools-0.1.0.dist-info/RECORD +150 -0
- munchboka_edutools-0.1.0.dist-info/WHEEL +4 -0
- munchboka_edutools-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,3012 @@
|
|
|
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
|
+
xmin,xmax,ymin,ymax Axis bounds (defaults ±6).
|
|
73
|
+
xstep,ystep Tick spacing (default 1).
|
|
74
|
+
ticks ``true|false`` master toggle for ticks/labels.
|
|
75
|
+
xticks|yticks ``off`` to remove one axis’s ticks.
|
|
76
|
+
grid ``true|false`` (independent of ``ticks``).
|
|
77
|
+
xlabel,ylabel Axis labels; can add a pad: ``xlabel: $t$, 8``.
|
|
78
|
+
axis Repeated key for special modes: ``off``, ``equal`` (may combine).
|
|
79
|
+
|
|
80
|
+
Expression support
|
|
81
|
+
------------------
|
|
82
|
+
All numeric coordinates (centers, radii, interval endpoints, slopes, etc.) may
|
|
83
|
+
be SymPy expressions. Allowed names include: ``pi``, ``E``, ``sqrt``, ``exp``,
|
|
84
|
+
``log``, ``sin``, ``cos``, ``tan``, ``asin``, ``acos``, ``atan``, ``Abs`` plus
|
|
85
|
+
basic arithmetic. A small, safe namespace is used; arbitrary Python execution
|
|
86
|
+
is not performed. If evaluation fails the item is skipped silently.
|
|
87
|
+
|
|
88
|
+
Functions
|
|
89
|
+
---------
|
|
90
|
+
Forms:
|
|
91
|
+
1. ``function: expr`` — variable is ``x``.
|
|
92
|
+
2. ``function: expr, label``
|
|
93
|
+
3. ``function: expr (a,b)`` — domain restriction (open interval).
|
|
94
|
+
4. ``function: expr (a,b) \ {x1, x2}`` — exclusions inside domain.
|
|
95
|
+
5. List / tuple literal mixing expression, label, domain, exclusion set in
|
|
96
|
+
any order: ``function: [expr, "f(x)", (a,b), {x1, x2}]``.
|
|
97
|
+
|
|
98
|
+
Notes:
|
|
99
|
+
* Discontinuities and exclusions are split to avoid vertical strokes.
|
|
100
|
+
* Single‑letter labels are preserved (``g`` no longer coerced into a color).
|
|
101
|
+
* Labels auto‑wrapped for math; don’t include surrounding ``$``.
|
|
102
|
+
|
|
103
|
+
Points
|
|
104
|
+
------
|
|
105
|
+
``point: (x, y)`` where each coordinate can be an expression or a *function
|
|
106
|
+
label call* of the form ``label(number)`` (e.g. ``point: (2, f(2))``).
|
|
107
|
+
|
|
108
|
+
Vertical / horizontal lines
|
|
109
|
+
---------------------------
|
|
110
|
+
``vline: x[, ymin, ymax][, linestyle][, color]``
|
|
111
|
+
``hline: y[, x0, x1][, linestyle][, color]``
|
|
112
|
+
Linestyles: ``solid|dotted|dashed|dashdot`` (order with color is free). Omitted
|
|
113
|
+
range endpoints default to current axis min/max.
|
|
114
|
+
|
|
115
|
+
General line and segments
|
|
116
|
+
-------------------------
|
|
117
|
+
``line: a, (x0, y0)[, linestyle][, color]`` — draws ``y = a*(x - x0) + y0``.
|
|
118
|
+
``line: a, b[, linestyle][, color]`` — draws ``y = a*x + b``.
|
|
119
|
+
``line-segment: (x1, y1), (x2, y2)[, linestyle][, color]`` — finite segment.
|
|
120
|
+
|
|
121
|
+
Polygons
|
|
122
|
+
--------
|
|
123
|
+
``polygon: (x1, y1), (x2, y2), ...[, show_vertices]`` — edges.
|
|
124
|
+
``fill-polygon: (..)[, color][, alpha]`` — filled interior; first non‑numeric
|
|
125
|
+
extra = color, first numeric extra = alpha (default 0.1).
|
|
126
|
+
Coordinates accept expressions and function label calls.
|
|
127
|
+
|
|
128
|
+
Bars
|
|
129
|
+
----
|
|
130
|
+
``bar: (x, y), length, orientation`` where orientation is one of
|
|
131
|
+
``h|hor|horiz|horizontal`` or ``v|vert|vertical``.
|
|
132
|
+
|
|
133
|
+
Vectors
|
|
134
|
+
-------
|
|
135
|
+
``vector: x, y, dx, dy[, color]`` — all four numeric fields accept expressions.
|
|
136
|
+
Color mapped through palette then fallback to literal then black.
|
|
137
|
+
|
|
138
|
+
Angle arcs
|
|
139
|
+
----------
|
|
140
|
+
``angle-arc: (cx, cy), radius, start_deg, end_deg[, linestyle][, color]``.
|
|
141
|
+
Angles in *degrees* (mathematical CCW convention). All numeric parts allow
|
|
142
|
+
expressions. Optional style/color order‑independent after the first three
|
|
143
|
+
expressions.
|
|
144
|
+
|
|
145
|
+
Circles & ellipses
|
|
146
|
+
------------------
|
|
147
|
+
Circle: ``circle: (cx, cy), r[, linestyle][, color]`` (r > 0).
|
|
148
|
+
Ellipse: ``ellipse: (cx, cy), a, b[, linestyle][, color]`` (a,b > 0).
|
|
149
|
+
Both sample 1024 points; style/color optional and order‑independent.
|
|
150
|
+
|
|
151
|
+
Parametric curves
|
|
152
|
+
-----------------
|
|
153
|
+
``curve: x_expr, y_expr, (t0, t1)[, linestyle][, color]`` — samples 1024 points
|
|
154
|
+
with ``t`` symbol. Interval endpoints may be expressions (auto‑swapped if
|
|
155
|
+
reversed). Style/color optional.
|
|
156
|
+
|
|
157
|
+
Annotations & text
|
|
158
|
+
------------------
|
|
159
|
+
``annotate: (xytext), (xy), "text"[, arc]`` — arrow annotation; coordinates &
|
|
160
|
+
arc curvature can be expressions.
|
|
161
|
+
``text: [x, y, string[, pos][, bbox]]`` — position tokens: ``top-left``,
|
|
162
|
+
``center-center``, etc.; *long* variants shift further (e.g. ``longtop-left``).
|
|
163
|
+
|
|
164
|
+
Axis overrides: off / equal
|
|
165
|
+
---------------------------
|
|
166
|
+
``axis: off`` hides frame & ticks (manual artists still drawn).
|
|
167
|
+
``axis: equal`` enforces 1:1 aspect (may combine with ``off``). Additional
|
|
168
|
+
``axis: tight`` etc. still applied in visible‑axis mode.
|
|
169
|
+
|
|
170
|
+
Color & linestyle resolution
|
|
171
|
+
----------------------------
|
|
172
|
+
1. Try ``plotmath.COLORS[name]``.
|
|
173
|
+
2. Fallback to the literal (Matplotlib named or hex) token.
|
|
174
|
+
3. Fallback default (e.g. black, red, blue depending on primitive).
|
|
175
|
+
Single‑letter Matplotlib shorthands are *disabled for function labels* to
|
|
176
|
+
avoid ambiguity.
|
|
177
|
+
|
|
178
|
+
Safety & robustness
|
|
179
|
+
-------------------
|
|
180
|
+
* Expression evaluation uses SymPy in a restricted namespace (no exec / eval).
|
|
181
|
+
* Any parsing failure for an individual primitive silently skips that item.
|
|
182
|
+
* Cached SVGs stored under ``_static/plot/`` keyed by a content hash unless a
|
|
183
|
+
``name:`` override is supplied.
|
|
184
|
+
|
|
185
|
+
Caption
|
|
186
|
+
-------
|
|
187
|
+
Lines after a blank line (or after the front matter fence) become the caption.
|
|
188
|
+
|
|
189
|
+
This docstring is intentionally exhaustive; HOWTOWRITE.md contains user‑facing
|
|
190
|
+
Norwegian examples.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
from __future__ import annotations
|
|
194
|
+
|
|
195
|
+
import ast
|
|
196
|
+
import hashlib
|
|
197
|
+
import os
|
|
198
|
+
import re
|
|
199
|
+
import shutil
|
|
200
|
+
import uuid
|
|
201
|
+
from typing import Any, Callable, Dict, List, Tuple
|
|
202
|
+
|
|
203
|
+
from docutils import nodes
|
|
204
|
+
from docutils.parsers.rst import directives
|
|
205
|
+
from sphinx.util.docutils import SphinxDirective
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ------------------------------------
|
|
209
|
+
# Utilities
|
|
210
|
+
# ------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _hash_key(*parts) -> str:
|
|
214
|
+
h = hashlib.sha1()
|
|
215
|
+
for p in parts:
|
|
216
|
+
if p is None:
|
|
217
|
+
p = "__NONE__"
|
|
218
|
+
h.update(str(p).encode("utf-8"))
|
|
219
|
+
h.update(b"||")
|
|
220
|
+
return h.hexdigest()[:12]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _compile_function(expr: str) -> Callable:
|
|
224
|
+
import sympy, numpy as np
|
|
225
|
+
|
|
226
|
+
expr = expr.strip()
|
|
227
|
+
x = sympy.symbols("x")
|
|
228
|
+
try:
|
|
229
|
+
sym = sympy.sympify(expr)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
raise ValueError(f"Ugyldig funksjonsuttrykk '{expr}': {e}")
|
|
232
|
+
# If the expression does not depend on x, treat it as a constant function
|
|
233
|
+
if not sym.free_symbols or sym.free_symbols.isdisjoint({x}):
|
|
234
|
+
const_val = float(sym.evalf())
|
|
235
|
+
|
|
236
|
+
def f(arr):
|
|
237
|
+
a = np.asarray(arr, dtype=float)
|
|
238
|
+
return np.full_like(a, fill_value=const_val, dtype=float)
|
|
239
|
+
|
|
240
|
+
_ = f([0.0, 1.0])
|
|
241
|
+
return f
|
|
242
|
+
|
|
243
|
+
fn_np = sympy.lambdify(x, sym, modules=["numpy"])
|
|
244
|
+
|
|
245
|
+
def f(arr):
|
|
246
|
+
return fn_np(np.asarray(arr, dtype=float))
|
|
247
|
+
|
|
248
|
+
_ = f([0.0, 1.0])
|
|
249
|
+
return f
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_bool(val, default: bool | None = None) -> bool | None:
|
|
253
|
+
if val is None:
|
|
254
|
+
return default
|
|
255
|
+
if isinstance(val, bool):
|
|
256
|
+
return val
|
|
257
|
+
s = str(val).strip().lower()
|
|
258
|
+
if s == "":
|
|
259
|
+
return True
|
|
260
|
+
if s in {"true", "yes", "on", "1"}:
|
|
261
|
+
return True
|
|
262
|
+
if s in {"false", "no", "off", "0"}:
|
|
263
|
+
return False
|
|
264
|
+
return default
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _strip_root_svg_size(svg_text: str) -> str:
|
|
268
|
+
def repl(m):
|
|
269
|
+
tag = m.group(0)
|
|
270
|
+
tag = re.sub(r'\swidth="[^"]+"', "", tag)
|
|
271
|
+
tag = re.sub(r'\sheight="[^"]+"', "", tag)
|
|
272
|
+
return tag
|
|
273
|
+
|
|
274
|
+
return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _rewrite_ids(txt: str, prefix: str) -> str:
|
|
278
|
+
ids = re.findall(r'\bid="([^"]+)"', txt)
|
|
279
|
+
if not ids:
|
|
280
|
+
return txt
|
|
281
|
+
skip_prefixes = (
|
|
282
|
+
"DejaVu",
|
|
283
|
+
"CM",
|
|
284
|
+
"STIX",
|
|
285
|
+
"Nimbus",
|
|
286
|
+
"Bitstream",
|
|
287
|
+
"Arial",
|
|
288
|
+
"Times",
|
|
289
|
+
"Helvetica",
|
|
290
|
+
)
|
|
291
|
+
mapping = {}
|
|
292
|
+
for i in ids:
|
|
293
|
+
if i.startswith(skip_prefixes):
|
|
294
|
+
continue
|
|
295
|
+
mapping[i] = f"{prefix}{i}"
|
|
296
|
+
if not mapping:
|
|
297
|
+
return txt
|
|
298
|
+
|
|
299
|
+
def repl_id(m: re.Match) -> str:
|
|
300
|
+
old = m.group(1)
|
|
301
|
+
new = mapping.get(old, old)
|
|
302
|
+
return f'id="{new}"'
|
|
303
|
+
|
|
304
|
+
txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
|
|
305
|
+
|
|
306
|
+
def repl_url(m: re.Match) -> str:
|
|
307
|
+
old = m.group(1).strip()
|
|
308
|
+
new = mapping.get(old, old)
|
|
309
|
+
return f"url(#{new})"
|
|
310
|
+
|
|
311
|
+
txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
|
|
312
|
+
|
|
313
|
+
def repl_href(m: re.Match) -> str:
|
|
314
|
+
attr = m.group(1)
|
|
315
|
+
quote = m.group(2)
|
|
316
|
+
old = m.group(3).strip()
|
|
317
|
+
new = mapping.get(old, old)
|
|
318
|
+
return f"{attr}={quote}#{new}{quote}"
|
|
319
|
+
|
|
320
|
+
txt = re.sub(r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2', repl_href, txt)
|
|
321
|
+
return txt
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _safe_literal(val: str):
|
|
325
|
+
try:
|
|
326
|
+
return ast.literal_eval(val)
|
|
327
|
+
except Exception:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _split_list(val: str) -> List[str]:
|
|
332
|
+
s = str(val or "").strip()
|
|
333
|
+
if not s:
|
|
334
|
+
return []
|
|
335
|
+
if s.startswith("[") and s.endswith("]"):
|
|
336
|
+
s = s[1:-1]
|
|
337
|
+
s = s.replace(";", ",")
|
|
338
|
+
return [p.strip() for p in s.split(",") if p.strip()]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _parse_text_positioning(pos: str) -> Tuple[str, str]:
|
|
342
|
+
"""Map positioning string to (va, ha). Default is (top, left).
|
|
343
|
+
|
|
344
|
+
Accepted values (case-insensitive, hyphen or underscore allowed):
|
|
345
|
+
- top-left, top-right, bottom-left, bottom-right,
|
|
346
|
+
- top-center, bottom-center, center-left, center-right
|
|
347
|
+
"""
|
|
348
|
+
if not isinstance(pos, str):
|
|
349
|
+
return ("top", "left")
|
|
350
|
+
key = pos.strip().lower().replace("_", "-")
|
|
351
|
+
|
|
352
|
+
# Map onto the opposites to make intuitive sense.
|
|
353
|
+
# Matplotlib expects the position to refer to where the object is relative to the text.
|
|
354
|
+
# Thus "left" means the object is to the "left" of the text.
|
|
355
|
+
# Here "left" will mean "move the text to the left of the object"
|
|
356
|
+
mapping = {
|
|
357
|
+
"top-left": ("bottom", "right"),
|
|
358
|
+
"top-right": ("bottom", "left"),
|
|
359
|
+
"bottom-left": ("top", "right"),
|
|
360
|
+
"bottom-right": ("top", "left"),
|
|
361
|
+
"top-center": ("bottom", "center"),
|
|
362
|
+
"bottom-center": ("top", "center"),
|
|
363
|
+
"center-left": ("center", "right"),
|
|
364
|
+
"center-right": ("center", "left"),
|
|
365
|
+
# Longer distance from point
|
|
366
|
+
"longtop-left": ("longbottom", "left"),
|
|
367
|
+
"longtop-longleft": ("longbottom", "longright"),
|
|
368
|
+
"longbottom-right": ("longtop", "right"),
|
|
369
|
+
"longbottom-left": ("longtop", "left"),
|
|
370
|
+
"longtop-center": ("longbottom", "center"),
|
|
371
|
+
"longbottom-center": ("longtop", "center"),
|
|
372
|
+
"longtop-longright": ("longbottom", "longleft"),
|
|
373
|
+
"longbottom-longright": ("longtop", "longleft"),
|
|
374
|
+
"longtop-longleft": ("longbottom", "longright"),
|
|
375
|
+
"longbottom-longleft": ("longtop", "longright"),
|
|
376
|
+
"top-longleft": ("bottom", "longright"),
|
|
377
|
+
"top-longright": ("bottom", "longleft"),
|
|
378
|
+
"bottom-longleft": ("top", "longright"),
|
|
379
|
+
"bottom-longright": ("top", "longleft"),
|
|
380
|
+
"center-longleft": ("center", "longright"),
|
|
381
|
+
"center-longright": ("center", "longleft"),
|
|
382
|
+
"center-center": ("center", "center"),
|
|
383
|
+
}
|
|
384
|
+
return mapping.get(key, ("top", "left"))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class PlotDirective(SphinxDirective):
|
|
388
|
+
has_content = True
|
|
389
|
+
required_arguments = 0
|
|
390
|
+
option_spec = {
|
|
391
|
+
# presentation / misc
|
|
392
|
+
"width": directives.length_or_percentage_or_unitless,
|
|
393
|
+
"align": lambda a: directives.choice(a, ["left", "center", "right"]),
|
|
394
|
+
"class": directives.class_option,
|
|
395
|
+
"name": directives.unchanged,
|
|
396
|
+
"nocache": directives.flag,
|
|
397
|
+
"debug": directives.flag,
|
|
398
|
+
"alt": directives.unchanged,
|
|
399
|
+
# text rendering
|
|
400
|
+
"usetex": directives.unchanged,
|
|
401
|
+
# axes options (optional in YAML too)
|
|
402
|
+
"xmin": directives.unchanged,
|
|
403
|
+
"xmax": directives.unchanged,
|
|
404
|
+
"ymin": directives.unchanged,
|
|
405
|
+
"ymax": directives.unchanged,
|
|
406
|
+
"xstep": directives.unchanged,
|
|
407
|
+
"ystep": directives.unchanged,
|
|
408
|
+
"fontsize": directives.unchanged,
|
|
409
|
+
"ticks": directives.unchanged,
|
|
410
|
+
"grid": directives.unchanged,
|
|
411
|
+
"xticks": directives.unchanged,
|
|
412
|
+
"yticks": directives.unchanged,
|
|
413
|
+
"lw": directives.unchanged,
|
|
414
|
+
"alpha": directives.unchanged,
|
|
415
|
+
"figsize": directives.unchanged,
|
|
416
|
+
# axis labels
|
|
417
|
+
"xlabel": directives.unchanged,
|
|
418
|
+
"ylabel": directives.unchanged,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
def _parse_kv_block(self) -> Tuple[Dict[str, Any], Dict[str, List[str]], int]:
|
|
422
|
+
"""Parse front matter supporting repeated keys for function/point/annotate.
|
|
423
|
+
|
|
424
|
+
Returns: (scalars, lists, caption_idx)
|
|
425
|
+
"""
|
|
426
|
+
lines = list(self.content)
|
|
427
|
+
scalars: Dict[str, Any] = {}
|
|
428
|
+
lists: Dict[str, List[str]] = {
|
|
429
|
+
"function": [],
|
|
430
|
+
"point": [],
|
|
431
|
+
"annotate": [],
|
|
432
|
+
"text": [],
|
|
433
|
+
"vline": [],
|
|
434
|
+
"hline": [],
|
|
435
|
+
"line": [],
|
|
436
|
+
"polygon": [],
|
|
437
|
+
"axis": [],
|
|
438
|
+
"fill-polygon": [],
|
|
439
|
+
"bar": [],
|
|
440
|
+
"vector": [],
|
|
441
|
+
"line-segment": [],
|
|
442
|
+
"angle-arc": [],
|
|
443
|
+
"circle": [],
|
|
444
|
+
"ellipse": [],
|
|
445
|
+
"curve": [],
|
|
446
|
+
}
|
|
447
|
+
# YAML-like fenced front matter
|
|
448
|
+
if lines and lines[0].strip() == "---":
|
|
449
|
+
idx = 1
|
|
450
|
+
while idx < len(lines) and lines[idx].strip() != "---":
|
|
451
|
+
line = lines[idx].rstrip()
|
|
452
|
+
if not line.strip():
|
|
453
|
+
idx += 1
|
|
454
|
+
continue
|
|
455
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
456
|
+
if m:
|
|
457
|
+
key, value = m.group(1), m.group(2)
|
|
458
|
+
if key in lists:
|
|
459
|
+
lists[key].append(value)
|
|
460
|
+
else:
|
|
461
|
+
scalars[key] = value
|
|
462
|
+
idx += 1
|
|
463
|
+
# Skip closing fence if present
|
|
464
|
+
if idx < len(lines) and lines[idx].strip() == "---":
|
|
465
|
+
idx += 1
|
|
466
|
+
# Skip trailing blanks before caption
|
|
467
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
468
|
+
idx += 1
|
|
469
|
+
return scalars, lists, idx
|
|
470
|
+
|
|
471
|
+
# Fallback: non-fenced lines until first non "key: value" or a blank separator
|
|
472
|
+
caption_start = 0
|
|
473
|
+
for i, line in enumerate(lines):
|
|
474
|
+
if not line.strip():
|
|
475
|
+
caption_start = i + 1
|
|
476
|
+
continue
|
|
477
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
478
|
+
if m:
|
|
479
|
+
key, value = m.group(1), m.group(2)
|
|
480
|
+
if key in lists:
|
|
481
|
+
lists[key].append(value)
|
|
482
|
+
else:
|
|
483
|
+
scalars[key] = value
|
|
484
|
+
caption_start = i + 1
|
|
485
|
+
else:
|
|
486
|
+
break
|
|
487
|
+
return scalars, lists, caption_start
|
|
488
|
+
|
|
489
|
+
def run(self):
|
|
490
|
+
env = self.state.document.settings.env
|
|
491
|
+
app = env.app
|
|
492
|
+
try:
|
|
493
|
+
import plotmath # type: ignore
|
|
494
|
+
import numpy as np # used for x sampling when plotting functions
|
|
495
|
+
except Exception:
|
|
496
|
+
# Fall back to bundled development shim so tests and docs can build without external plotmath
|
|
497
|
+
try:
|
|
498
|
+
from munchboka_edutools import _plotmath_shim as plotmath # type: ignore
|
|
499
|
+
import numpy as np # used for x sampling when plotting functions
|
|
500
|
+
except Exception as e:
|
|
501
|
+
err = nodes.error()
|
|
502
|
+
err += nodes.paragraph(text=f"Kunne ikke importere plotmath: {e}")
|
|
503
|
+
return [err]
|
|
504
|
+
|
|
505
|
+
scalars, lists, caption_idx = self._parse_kv_block()
|
|
506
|
+
merged: Dict[str, Any] = {**scalars, **self.options}
|
|
507
|
+
|
|
508
|
+
# debug print removed
|
|
509
|
+
|
|
510
|
+
def _f(name, default):
|
|
511
|
+
v = merged.get(name)
|
|
512
|
+
if v in (None, ""):
|
|
513
|
+
return default
|
|
514
|
+
try:
|
|
515
|
+
return float(v)
|
|
516
|
+
except Exception:
|
|
517
|
+
return default
|
|
518
|
+
|
|
519
|
+
xmin = _f("xmin", -6)
|
|
520
|
+
xmax = _f("xmax", 6)
|
|
521
|
+
ymin = _f("ymin", -6)
|
|
522
|
+
ymax = _f("ymax", 6)
|
|
523
|
+
xstep = _f("xstep", 1)
|
|
524
|
+
ystep = _f("ystep", 1)
|
|
525
|
+
fontsize = _f("fontsize", 20)
|
|
526
|
+
lw = _f("lw", 2.5)
|
|
527
|
+
alpha_raw = merged.get("alpha")
|
|
528
|
+
figsize_raw = merged.get("figsize")
|
|
529
|
+
try:
|
|
530
|
+
alpha = float(alpha_raw) if alpha_raw not in (None, "") else None
|
|
531
|
+
except Exception:
|
|
532
|
+
alpha = None
|
|
533
|
+
|
|
534
|
+
ticks_flag = _parse_bool(merged.get("ticks"), default=None)
|
|
535
|
+
grid_flag = _parse_bool(merged.get("grid"), default=None)
|
|
536
|
+
|
|
537
|
+
# Set defaults: if neither is specified, both default to True
|
|
538
|
+
if ticks_flag is None and grid_flag is None:
|
|
539
|
+
ticks_flag = True
|
|
540
|
+
grid_flag = True
|
|
541
|
+
else:
|
|
542
|
+
# If ticks not explicitly set, default to True (independent of grid setting)
|
|
543
|
+
if ticks_flag is None:
|
|
544
|
+
ticks_flag = True
|
|
545
|
+
else:
|
|
546
|
+
ticks_flag = bool(ticks_flag)
|
|
547
|
+
|
|
548
|
+
# If grid not explicitly set, default to True (independent of ticks setting)
|
|
549
|
+
if grid_flag is None:
|
|
550
|
+
grid_flag = True
|
|
551
|
+
else:
|
|
552
|
+
grid_flag = bool(grid_flag)
|
|
553
|
+
|
|
554
|
+
# Compile functions (may be zero or many) and parse optional labels, optional domain (xmin,xmax), exclusions and color
|
|
555
|
+
raw_fn_items = lists.get("function", [])
|
|
556
|
+
fn_exprs: List[str] = []
|
|
557
|
+
fn_labels_list: List[str] = []
|
|
558
|
+
fn_domains_list: List[Tuple[float, float] | None] = []
|
|
559
|
+
fn_exclusions_list: List[List[float]] = []
|
|
560
|
+
fn_colors_list: List[str] = []
|
|
561
|
+
functions: List[Callable] = []
|
|
562
|
+
|
|
563
|
+
def _parse_function_item(
|
|
564
|
+
s: str,
|
|
565
|
+
) -> Tuple[
|
|
566
|
+
str,
|
|
567
|
+
str | None,
|
|
568
|
+
Tuple[float, float] | None,
|
|
569
|
+
List[float],
|
|
570
|
+
str | None,
|
|
571
|
+
]:
|
|
572
|
+
s = str(s).strip()
|
|
573
|
+
|
|
574
|
+
# Heuristic to detect if a string token looks like a color
|
|
575
|
+
def _looks_like_color(tok: str) -> bool:
|
|
576
|
+
if not isinstance(tok, str):
|
|
577
|
+
return False
|
|
578
|
+
t = tok.strip()
|
|
579
|
+
if not t:
|
|
580
|
+
return False
|
|
581
|
+
# hex colors
|
|
582
|
+
if re.match(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", t):
|
|
583
|
+
return True
|
|
584
|
+
# IMPORTANT: single-letter matplotlib shorthands intentionally
|
|
585
|
+
# NOT treated as colors here so that users can name functions
|
|
586
|
+
# with letters like 'g' without it being consumed as green.
|
|
587
|
+
# Use full names (e.g. 'green') or color=green instead.
|
|
588
|
+
if t.lower().startswith("tab:"):
|
|
589
|
+
return True
|
|
590
|
+
if re.match(r"^C\d+$", t):
|
|
591
|
+
return True
|
|
592
|
+
# plotmath named colors
|
|
593
|
+
try:
|
|
594
|
+
import plotmath as _pm
|
|
595
|
+
|
|
596
|
+
if _pm.COLORS.get(t) is not None:
|
|
597
|
+
return True
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
return False
|
|
601
|
+
|
|
602
|
+
# Try literal list/tuple like ("expr", "label") or ("expr", (xmin,xmax)) or ("expr", "label", (xmin,xmax)) in any order
|
|
603
|
+
lit = _safe_literal(s)
|
|
604
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 1:
|
|
605
|
+
expr = str(lit[0]).strip()
|
|
606
|
+
label: str | None = None
|
|
607
|
+
domain: Tuple[float, float] | None = None
|
|
608
|
+
excludes: List[float] = []
|
|
609
|
+
color: str | None = None
|
|
610
|
+
for item in list(lit[1:]):
|
|
611
|
+
# Detect domain tuple/list of two numbers
|
|
612
|
+
if domain is None and isinstance(item, (list, tuple)) and len(item) == 2:
|
|
613
|
+
try:
|
|
614
|
+
d0 = float(item[0])
|
|
615
|
+
d1 = float(item[1])
|
|
616
|
+
domain = (d0, d1)
|
|
617
|
+
continue
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
# Detect exclusions as a collection of numbers (not a 2-tuple domain)
|
|
621
|
+
if isinstance(item, (set, list, tuple)) and not (
|
|
622
|
+
isinstance(item, (list, tuple)) and len(item) == 2
|
|
623
|
+
):
|
|
624
|
+
for v in item:
|
|
625
|
+
try:
|
|
626
|
+
excludes.append(float(v))
|
|
627
|
+
except Exception:
|
|
628
|
+
pass
|
|
629
|
+
# Else string-like: support label/color, order-independent
|
|
630
|
+
if isinstance(item, str):
|
|
631
|
+
tok = item.strip()
|
|
632
|
+
if tok.lower().startswith("label="):
|
|
633
|
+
if label is None:
|
|
634
|
+
label = tok.split("=", 1)[1].strip()
|
|
635
|
+
continue
|
|
636
|
+
if tok.lower().startswith("color="):
|
|
637
|
+
if color is None:
|
|
638
|
+
color = tok.split("=", 1)[1].strip()
|
|
639
|
+
continue
|
|
640
|
+
if color is None and _looks_like_color(tok):
|
|
641
|
+
color = tok
|
|
642
|
+
elif label is None:
|
|
643
|
+
lab = tok
|
|
644
|
+
label = lab if lab else None
|
|
645
|
+
return expr, label, domain, excludes, color
|
|
646
|
+
# Fallback: attempt to locate a (domain) pattern (expr_a, expr_b) with fully balanced parentheses
|
|
647
|
+
# and optional exclusions of the form \{a,b,c}. Supports nested parentheses in each endpoint
|
|
648
|
+
# (e.g. (0, 2*sqrt(5)) or (pi/4, 3*pi/2 + sqrt(2))).
|
|
649
|
+
domain: Tuple[float, float] | None = None
|
|
650
|
+
excludes: List[float] = []
|
|
651
|
+
color: str | None = None
|
|
652
|
+
|
|
653
|
+
def _sym_eval_num(txt: str) -> float:
|
|
654
|
+
import sympy
|
|
655
|
+
|
|
656
|
+
allowed = {
|
|
657
|
+
k: getattr(sympy, k)
|
|
658
|
+
for k in [
|
|
659
|
+
"pi",
|
|
660
|
+
"E",
|
|
661
|
+
"exp",
|
|
662
|
+
"sqrt",
|
|
663
|
+
"log",
|
|
664
|
+
"sin",
|
|
665
|
+
"cos",
|
|
666
|
+
"tan",
|
|
667
|
+
"asin",
|
|
668
|
+
"acos",
|
|
669
|
+
"atan",
|
|
670
|
+
"Rational",
|
|
671
|
+
]
|
|
672
|
+
if hasattr(sympy, k)
|
|
673
|
+
}
|
|
674
|
+
expr = sympy.sympify(txt, locals=allowed)
|
|
675
|
+
return float(expr.evalf())
|
|
676
|
+
|
|
677
|
+
def _extract_domain_and_exclusions(text: str):
|
|
678
|
+
"""Return (domain_tuple|None, exclusions_list, text_without_domain).
|
|
679
|
+
Heuristic: find the first parenthesis block whose top-level content
|
|
680
|
+
splits into exactly two parts by a single top-level comma.
|
|
681
|
+
"""
|
|
682
|
+
n = len(text)
|
|
683
|
+
i = 0
|
|
684
|
+
while i < n:
|
|
685
|
+
if text[i] == "(":
|
|
686
|
+
depth = 1
|
|
687
|
+
j = i + 1
|
|
688
|
+
while j < n and depth > 0:
|
|
689
|
+
ch = text[j]
|
|
690
|
+
if ch == "(":
|
|
691
|
+
depth += 1
|
|
692
|
+
elif ch == ")":
|
|
693
|
+
depth -= 1
|
|
694
|
+
j += 1
|
|
695
|
+
if depth != 0:
|
|
696
|
+
# unbalanced, give up
|
|
697
|
+
break
|
|
698
|
+
content = text[i + 1 : j - 1].strip()
|
|
699
|
+
# Find a single top-level comma in content
|
|
700
|
+
depth2 = 0
|
|
701
|
+
comma_index: int | None = None
|
|
702
|
+
for k, ch in enumerate(content):
|
|
703
|
+
if ch == "(":
|
|
704
|
+
depth2 += 1
|
|
705
|
+
elif ch == ")":
|
|
706
|
+
depth2 -= 1
|
|
707
|
+
elif ch == "," and depth2 == 0:
|
|
708
|
+
if comma_index is None:
|
|
709
|
+
comma_index = k
|
|
710
|
+
else:
|
|
711
|
+
# more than one top-level comma => not domain
|
|
712
|
+
comma_index = None
|
|
713
|
+
break
|
|
714
|
+
if comma_index is not None:
|
|
715
|
+
left = content[:comma_index].strip()
|
|
716
|
+
right = content[comma_index + 1 :].strip()
|
|
717
|
+
if left and right:
|
|
718
|
+
# Optional exclusions right after ) like \{a,b}
|
|
719
|
+
k2 = j
|
|
720
|
+
excl_list: List[float] = []
|
|
721
|
+
# skip whitespace
|
|
722
|
+
while k2 < n and text[k2].isspace():
|
|
723
|
+
k2 += 1
|
|
724
|
+
if k2 < n and text[k2] == "\\":
|
|
725
|
+
k2 += 1
|
|
726
|
+
while k2 < n and text[k2].isspace():
|
|
727
|
+
k2 += 1
|
|
728
|
+
if k2 < n and text[k2] == "{":
|
|
729
|
+
k2 += 1
|
|
730
|
+
excl_start = k2
|
|
731
|
+
# read until matching '}' (no nesting expected here)
|
|
732
|
+
while k2 < n and text[k2] != "}":
|
|
733
|
+
k2 += 1
|
|
734
|
+
excl_content = text[excl_start:k2]
|
|
735
|
+
if k2 < n and text[k2] == "}":
|
|
736
|
+
k2 += 1 # consume '}'
|
|
737
|
+
for tok in [
|
|
738
|
+
t.strip() for t in excl_content.split(",") if t.strip()
|
|
739
|
+
]:
|
|
740
|
+
try:
|
|
741
|
+
excl_list.append(_sym_eval_num(tok))
|
|
742
|
+
except Exception:
|
|
743
|
+
pass
|
|
744
|
+
# Attempt numeric evaluation of endpoints
|
|
745
|
+
dom_tuple: Tuple[float, float] | None = None
|
|
746
|
+
try:
|
|
747
|
+
d0 = _sym_eval_num(left)
|
|
748
|
+
d1 = _sym_eval_num(right)
|
|
749
|
+
dom_tuple = (d0, d1)
|
|
750
|
+
except Exception:
|
|
751
|
+
dom_tuple = None
|
|
752
|
+
# Remove the consumed substring from original text
|
|
753
|
+
new_text = (text[:i] + text[k2:]).strip()
|
|
754
|
+
return dom_tuple, excl_list, new_text
|
|
755
|
+
# Move past this parenthesis group and continue scanning
|
|
756
|
+
i = j
|
|
757
|
+
continue
|
|
758
|
+
i += 1
|
|
759
|
+
return None, [], text
|
|
760
|
+
|
|
761
|
+
domain, excludes, s_wo_dom = _extract_domain_and_exclusions(s)
|
|
762
|
+
# Tokenize on commas to robustly drop empty segments created by domain removal
|
|
763
|
+
parts = [p.strip() for p in s_wo_dom.split(",") if p.strip()]
|
|
764
|
+
if parts:
|
|
765
|
+
expr = parts[0]
|
|
766
|
+
label = None
|
|
767
|
+
# Scan remaining tokens for label/color using heuristics
|
|
768
|
+
for tok in parts[1:]:
|
|
769
|
+
t = tok.strip()
|
|
770
|
+
if t.lower().startswith("label="):
|
|
771
|
+
if label is None:
|
|
772
|
+
label = t.split("=", 1)[1].strip()
|
|
773
|
+
continue
|
|
774
|
+
if t.lower().startswith("color="):
|
|
775
|
+
if color is None:
|
|
776
|
+
color = t.split("=", 1)[1].strip()
|
|
777
|
+
continue
|
|
778
|
+
if color is None and _looks_like_color(t):
|
|
779
|
+
color = t
|
|
780
|
+
elif label is None:
|
|
781
|
+
label = t
|
|
782
|
+
return expr, label, domain, excludes, color
|
|
783
|
+
# Only expression provided (or empty after cleanup)
|
|
784
|
+
return s_wo_dom.strip(), None, domain, excludes, None
|
|
785
|
+
|
|
786
|
+
for item in raw_fn_items:
|
|
787
|
+
expr, label, domain, excludes, color = _parse_function_item(item)
|
|
788
|
+
try:
|
|
789
|
+
functions.append(_compile_function(expr))
|
|
790
|
+
fn_exprs.append(expr)
|
|
791
|
+
fn_labels_list.append(label or "")
|
|
792
|
+
fn_domains_list.append(domain)
|
|
793
|
+
fn_exclusions_list.append(sorted(excludes))
|
|
794
|
+
fn_colors_list.append(color or "")
|
|
795
|
+
except Exception as ex:
|
|
796
|
+
return [
|
|
797
|
+
self.state_machine.reporter.error(
|
|
798
|
+
f"Ugyldig funksjon '{expr}': {ex}", line=self.lineno
|
|
799
|
+
)
|
|
800
|
+
]
|
|
801
|
+
|
|
802
|
+
# ------------------------------------
|
|
803
|
+
# Unified numeric expression evaluator
|
|
804
|
+
# Supports:
|
|
805
|
+
# * Plain numbers
|
|
806
|
+
# * Arithmetic & SymPy functions (sqrt, pi, sin, ...)
|
|
807
|
+
# * References to previously defined function labels: f(2), g(pi/4+1)
|
|
808
|
+
# Limitations: nested user function calls deeper than 50 rewrites are blocked.
|
|
809
|
+
# ------------------------------------
|
|
810
|
+
_num_cache: Dict[str, float] = {}
|
|
811
|
+
|
|
812
|
+
def _eval_expr(val) -> float:
|
|
813
|
+
import sympy, re
|
|
814
|
+
|
|
815
|
+
if val is None:
|
|
816
|
+
raise ValueError("Empty value")
|
|
817
|
+
if isinstance(val, (int, float)):
|
|
818
|
+
return float(val)
|
|
819
|
+
s0 = str(val).strip()
|
|
820
|
+
if not s0:
|
|
821
|
+
raise ValueError("Blank numeric expression")
|
|
822
|
+
if s0 in _num_cache:
|
|
823
|
+
return _num_cache[s0]
|
|
824
|
+
s = s0
|
|
825
|
+
# Replace user function label calls iteratively.
|
|
826
|
+
# Allow nested parentheses now by a balanced scan: we match label( ... ) at top-level of that paren group.
|
|
827
|
+
pat = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\(")
|
|
828
|
+
# Simpler: fallback to previous non-nested approach but broaden attempt; for safety keep prior pattern.
|
|
829
|
+
pat_simple = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\(([^()]+)\)")
|
|
830
|
+
for _ in range(50):
|
|
831
|
+
m = pat_simple.search(s)
|
|
832
|
+
if not m:
|
|
833
|
+
break
|
|
834
|
+
lbl, arg_expr = m.group(1), m.group(2)
|
|
835
|
+
if lbl in fn_labels_list:
|
|
836
|
+
try:
|
|
837
|
+
arg_val = _eval_expr(arg_expr)
|
|
838
|
+
idx = fn_labels_list.index(lbl)
|
|
839
|
+
f = functions[idx]
|
|
840
|
+
yv = float(f([arg_val])[0])
|
|
841
|
+
s = s[: m.start()] + f"{yv}" + s[m.end() :]
|
|
842
|
+
continue
|
|
843
|
+
except Exception:
|
|
844
|
+
# leave unresolved for sympy
|
|
845
|
+
pass
|
|
846
|
+
# If not user function, just proceed to next occurrence
|
|
847
|
+
# Remove nothing to avoid infinite loop: break
|
|
848
|
+
break
|
|
849
|
+
allowed = {
|
|
850
|
+
k: getattr(sympy, k)
|
|
851
|
+
for k in [
|
|
852
|
+
"pi",
|
|
853
|
+
"E",
|
|
854
|
+
"exp",
|
|
855
|
+
"sqrt",
|
|
856
|
+
"log",
|
|
857
|
+
"sin",
|
|
858
|
+
"cos",
|
|
859
|
+
"tan",
|
|
860
|
+
"asin",
|
|
861
|
+
"acos",
|
|
862
|
+
"atan",
|
|
863
|
+
"Rational",
|
|
864
|
+
]
|
|
865
|
+
if hasattr(sympy, k)
|
|
866
|
+
}
|
|
867
|
+
try:
|
|
868
|
+
expr = sympy.sympify(s, locals=allowed)
|
|
869
|
+
valf = float(expr.evalf())
|
|
870
|
+
_num_cache[s0] = valf
|
|
871
|
+
return valf
|
|
872
|
+
except Exception as e:
|
|
873
|
+
raise ValueError(f"Kunne ikke tolke numerisk uttrykk '{val}': {e}")
|
|
874
|
+
|
|
875
|
+
# Points
|
|
876
|
+
point_vals: List[Tuple[float, float]] = []
|
|
877
|
+
for p in lists.get("point", []):
|
|
878
|
+
lit = _safe_literal(p)
|
|
879
|
+
if isinstance(lit, (list, tuple)) and len(lit) == 2:
|
|
880
|
+
try:
|
|
881
|
+
x0 = _eval_expr(lit[0])
|
|
882
|
+
y0 = _eval_expr(lit[1])
|
|
883
|
+
point_vals.append((x0, y0))
|
|
884
|
+
except Exception:
|
|
885
|
+
pass
|
|
886
|
+
else:
|
|
887
|
+
# Support dynamic evaluation referencing previously defined function labels, e.g. (5, f(5))
|
|
888
|
+
# Simple pattern match for a parenthesized pair allowing arbitrary (non-comma) inner expressions.
|
|
889
|
+
ps = str(p).strip()
|
|
890
|
+
m_pair = re.match(r"^\(\s*([^,]+?)\s*,\s*([^,]+?)\s*\)$", ps)
|
|
891
|
+
if m_pair:
|
|
892
|
+
x_raw = m_pair.group(1).strip()
|
|
893
|
+
y_raw = m_pair.group(2).strip()
|
|
894
|
+
try:
|
|
895
|
+
x_val = _eval_expr(x_raw)
|
|
896
|
+
except Exception:
|
|
897
|
+
# If x itself references a function call label(arg)
|
|
898
|
+
m_fx = re.match(
|
|
899
|
+
r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([+-]?(?:\d+(?:\.\d+)?))\s*\)$",
|
|
900
|
+
x_raw,
|
|
901
|
+
)
|
|
902
|
+
if m_fx:
|
|
903
|
+
lbl = m_fx.group(1)
|
|
904
|
+
arg = float(m_fx.group(2))
|
|
905
|
+
try:
|
|
906
|
+
idx = fn_labels_list.index(lbl)
|
|
907
|
+
x_val = float(functions[idx]([arg])[0])
|
|
908
|
+
except Exception:
|
|
909
|
+
continue # give up on this point
|
|
910
|
+
else:
|
|
911
|
+
continue
|
|
912
|
+
# y may be a direct float or a function label call like f(5)
|
|
913
|
+
try:
|
|
914
|
+
y_val = _eval_expr(y_raw)
|
|
915
|
+
except Exception:
|
|
916
|
+
m_fy = re.match(
|
|
917
|
+
r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([+-]?(?:\d+(?:\.\d+)?))\s*\)$",
|
|
918
|
+
y_raw,
|
|
919
|
+
)
|
|
920
|
+
if not m_fy:
|
|
921
|
+
continue
|
|
922
|
+
lbl = m_fy.group(1)
|
|
923
|
+
arg = float(m_fy.group(2))
|
|
924
|
+
if lbl in fn_labels_list:
|
|
925
|
+
try:
|
|
926
|
+
idx = fn_labels_list.index(lbl)
|
|
927
|
+
y_val = float(functions[idx]([arg])[0])
|
|
928
|
+
except Exception:
|
|
929
|
+
continue
|
|
930
|
+
else:
|
|
931
|
+
continue
|
|
932
|
+
try:
|
|
933
|
+
point_vals.append((float(x_val), float(y_val)))
|
|
934
|
+
except Exception:
|
|
935
|
+
pass
|
|
936
|
+
|
|
937
|
+
# Annotations: [(xytext), (xy), "text", arc] OR without outer brackets:
|
|
938
|
+
# (xytext), (xy), "text"[, arc]
|
|
939
|
+
ann_vals: List[Tuple[Tuple[float, float], Tuple[float, float], str, float]] = []
|
|
940
|
+
|
|
941
|
+
def _annotate_fallback_parse(raw: str):
|
|
942
|
+
"""Fallback parser for annotate lines containing arbitrary expressions.
|
|
943
|
+
Expected pattern: (expr_x1, expr_y1), (expr_x2, expr_y2), "text"[, arc]
|
|
944
|
+
Returns list of ( (x1,y1), (x2,y2), text, arc_expr|None ).
|
|
945
|
+
"""
|
|
946
|
+
s = raw.strip()
|
|
947
|
+
out: List[Tuple[Tuple[str, str], Tuple[str, str], str, str | None]] = []
|
|
948
|
+
|
|
949
|
+
# Find first two balanced tuples
|
|
950
|
+
def _grab_tuple(start_index: int) -> Tuple[int, int, str] | None:
|
|
951
|
+
if start_index >= len(s) or s[start_index] != "(":
|
|
952
|
+
return None
|
|
953
|
+
depth = 0
|
|
954
|
+
for j in range(start_index, len(s)):
|
|
955
|
+
if s[j] == "(":
|
|
956
|
+
depth += 1
|
|
957
|
+
elif s[j] == ")":
|
|
958
|
+
depth -= 1
|
|
959
|
+
if depth == 0:
|
|
960
|
+
inner = s[start_index + 1 : j]
|
|
961
|
+
return (start_index, j, inner)
|
|
962
|
+
return None
|
|
963
|
+
|
|
964
|
+
# locate first '('
|
|
965
|
+
i1 = s.find("(")
|
|
966
|
+
if i1 == -1:
|
|
967
|
+
return out
|
|
968
|
+
t1 = _grab_tuple(i1)
|
|
969
|
+
if not t1:
|
|
970
|
+
return out
|
|
971
|
+
i2_search = t1[1] + 1
|
|
972
|
+
# skip commas/space
|
|
973
|
+
while i2_search < len(s) and s[i2_search] in " ,":
|
|
974
|
+
i2_search += 1
|
|
975
|
+
if i2_search >= len(s) or s[i2_search] != "(":
|
|
976
|
+
return out
|
|
977
|
+
t2 = _grab_tuple(i2_search)
|
|
978
|
+
if not t2:
|
|
979
|
+
return out
|
|
980
|
+
rest = s[t2[1] + 1 :].strip()
|
|
981
|
+
|
|
982
|
+
# Split tuple inners on top-level comma
|
|
983
|
+
def _split_pair(inner: str) -> Tuple[str, str] | None:
|
|
984
|
+
depth = 0
|
|
985
|
+
for k, ch in enumerate(inner):
|
|
986
|
+
if ch == "(":
|
|
987
|
+
depth += 1
|
|
988
|
+
elif ch == ")":
|
|
989
|
+
depth -= 1
|
|
990
|
+
elif ch == "," and depth == 0:
|
|
991
|
+
left = inner[:k].strip()
|
|
992
|
+
right = inner[k + 1 :].strip()
|
|
993
|
+
if left and right:
|
|
994
|
+
return (left, right)
|
|
995
|
+
return None
|
|
996
|
+
|
|
997
|
+
p1 = _split_pair(t1[2])
|
|
998
|
+
p2 = _split_pair(t2[2])
|
|
999
|
+
if not (p1 and p2):
|
|
1000
|
+
return out
|
|
1001
|
+
# Extract quoted text
|
|
1002
|
+
# Regex to capture either a double-quoted or single-quoted string
|
|
1003
|
+
m_txt = re.search(
|
|
1004
|
+
r"\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|'([^'\\]*(?:\\.[^'\\]*)*)'",
|
|
1005
|
+
rest,
|
|
1006
|
+
)
|
|
1007
|
+
if not m_txt:
|
|
1008
|
+
return out
|
|
1009
|
+
text = m_txt.group(1) if m_txt.group(1) is not None else m_txt.group(2)
|
|
1010
|
+
after = rest[m_txt.end() :].strip().lstrip(",").strip()
|
|
1011
|
+
arc_expr = after if after else None
|
|
1012
|
+
out.append(((p1[0], p1[1]), (p2[0], p2[1]), text, arc_expr))
|
|
1013
|
+
return out
|
|
1014
|
+
|
|
1015
|
+
for a in lists.get("annotate", []):
|
|
1016
|
+
lit = _safe_literal(a)
|
|
1017
|
+
# If user omitted surrounding brackets, try wrapping in [] for parsing
|
|
1018
|
+
if not (isinstance(lit, (list, tuple)) and len(lit) >= 3):
|
|
1019
|
+
lit_wrapped = _safe_literal(f"[{a}]")
|
|
1020
|
+
if isinstance(lit_wrapped, list) and len(lit_wrapped) >= 3:
|
|
1021
|
+
lit = lit_wrapped
|
|
1022
|
+
added_before = len(ann_vals)
|
|
1023
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 3:
|
|
1024
|
+
xytext, xy, text = lit[0], lit[1], lit[2]
|
|
1025
|
+
# arc (arrow curvature) may be an expression
|
|
1026
|
+
try:
|
|
1027
|
+
arc = _eval_expr(str(lit[3])) if len(lit) > 3 else 0.3
|
|
1028
|
+
except Exception:
|
|
1029
|
+
try:
|
|
1030
|
+
arc = float(lit[3]) if len(lit) > 3 else 0.3
|
|
1031
|
+
except Exception:
|
|
1032
|
+
arc = 0.3
|
|
1033
|
+
try:
|
|
1034
|
+
# Allow expression coordinates. Expect xytext and xy to be (x,y)-like iterables
|
|
1035
|
+
xyt0 = _eval_expr(str(xytext[0]))
|
|
1036
|
+
xyt1 = _eval_expr(str(xytext[1]))
|
|
1037
|
+
xy0 = _eval_expr(str(xy[0]))
|
|
1038
|
+
xy1 = _eval_expr(str(xy[1]))
|
|
1039
|
+
xytext = (float(xyt0), float(xyt1))
|
|
1040
|
+
xy = (float(xy0), float(xy1))
|
|
1041
|
+
text = str(text)
|
|
1042
|
+
ann_vals.append((xytext, xy, text, float(arc)))
|
|
1043
|
+
except Exception:
|
|
1044
|
+
# Fallback: attempt plain float conversion
|
|
1045
|
+
try:
|
|
1046
|
+
xytext = (float(xytext[0]), float(xytext[1]))
|
|
1047
|
+
xy = (float(xy[0]), float(xy[1]))
|
|
1048
|
+
text = str(text)
|
|
1049
|
+
ann_vals.append((xytext, xy, text, float(arc)))
|
|
1050
|
+
except Exception:
|
|
1051
|
+
pass
|
|
1052
|
+
# Fallback parsing if nothing appended
|
|
1053
|
+
if len(ann_vals) == added_before:
|
|
1054
|
+
for (
|
|
1055
|
+
(x1_expr, y1_expr),
|
|
1056
|
+
(x2_expr, y2_expr),
|
|
1057
|
+
text_s,
|
|
1058
|
+
arc_expr,
|
|
1059
|
+
) in _annotate_fallback_parse(str(a)):
|
|
1060
|
+
try:
|
|
1061
|
+
x1 = _eval_expr(x1_expr)
|
|
1062
|
+
y1 = _eval_expr(y1_expr)
|
|
1063
|
+
except Exception:
|
|
1064
|
+
continue
|
|
1065
|
+
try:
|
|
1066
|
+
x2 = _eval_expr(x2_expr)
|
|
1067
|
+
y2 = _eval_expr(y2_expr)
|
|
1068
|
+
except Exception:
|
|
1069
|
+
continue
|
|
1070
|
+
# arc expression optional
|
|
1071
|
+
arc_val = 0.3
|
|
1072
|
+
if arc_expr:
|
|
1073
|
+
try:
|
|
1074
|
+
arc_val = _eval_expr(arc_expr)
|
|
1075
|
+
except Exception:
|
|
1076
|
+
try:
|
|
1077
|
+
arc_val = float(arc_expr)
|
|
1078
|
+
except Exception:
|
|
1079
|
+
pass
|
|
1080
|
+
ann_vals.append(
|
|
1081
|
+
(
|
|
1082
|
+
(float(x1), float(y1)),
|
|
1083
|
+
(float(x2), float(y2)),
|
|
1084
|
+
str(text_s),
|
|
1085
|
+
float(arc_val),
|
|
1086
|
+
)
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
# Text: x, y, text, optional positioning, optional bbox flag
|
|
1090
|
+
# Accepted forms:
|
|
1091
|
+
# - [x, y, text]
|
|
1092
|
+
# - [x, y, text, pos]
|
|
1093
|
+
# - [x, y, text, bbox] # where bbox can be 'bbox' or a boolean (true/false)
|
|
1094
|
+
# - [x, y, text, pos, bbox]
|
|
1095
|
+
# CSV fallback supports the same arities; for 4 tokens, the 4th can be pos or bbox
|
|
1096
|
+
text_vals: List[Tuple[float, float, str, str, bool]] = []
|
|
1097
|
+
for t in lists.get("text", []):
|
|
1098
|
+
lit = _safe_literal(t)
|
|
1099
|
+
if isinstance(lit, (list, tuple)) and (3 <= len(lit) <= 5):
|
|
1100
|
+
try:
|
|
1101
|
+
# Allow expressions for x and y
|
|
1102
|
+
x = _eval_expr(str(lit[0]))
|
|
1103
|
+
y = _eval_expr(str(lit[1]))
|
|
1104
|
+
text = str(lit[2])
|
|
1105
|
+
pos = "top-left"
|
|
1106
|
+
bbox_flag = False
|
|
1107
|
+
if len(lit) == 4:
|
|
1108
|
+
token = lit[3]
|
|
1109
|
+
# If token is an explicit bbox flag
|
|
1110
|
+
if isinstance(token, str) and token.strip().lower() == "bbox":
|
|
1111
|
+
bbox_flag = True
|
|
1112
|
+
else:
|
|
1113
|
+
b = _parse_bool(token, default=None)
|
|
1114
|
+
if isinstance(b, bool):
|
|
1115
|
+
bbox_flag = bool(b)
|
|
1116
|
+
else:
|
|
1117
|
+
pos = str(token)
|
|
1118
|
+
elif len(lit) == 5:
|
|
1119
|
+
pos = str(lit[3])
|
|
1120
|
+
token = lit[4]
|
|
1121
|
+
if isinstance(token, str) and token.strip().lower() == "bbox":
|
|
1122
|
+
bbox_flag = True
|
|
1123
|
+
else:
|
|
1124
|
+
b = _parse_bool(token, default=None)
|
|
1125
|
+
if isinstance(b, bool):
|
|
1126
|
+
bbox_flag = bool(b)
|
|
1127
|
+
text_vals.append((x, y, text, pos, bbox_flag))
|
|
1128
|
+
continue
|
|
1129
|
+
except Exception:
|
|
1130
|
+
pass
|
|
1131
|
+
# Fallback: allow unquoted tokens like top-left using a CSV-style parse
|
|
1132
|
+
try:
|
|
1133
|
+
import csv
|
|
1134
|
+
|
|
1135
|
+
s = str(t).strip()
|
|
1136
|
+
# strip surrounding brackets/parentheses if present
|
|
1137
|
+
if (s.startswith("[") and s.endswith("]")) or (
|
|
1138
|
+
s.startswith("(") and s.endswith(")")
|
|
1139
|
+
):
|
|
1140
|
+
s = s[1:-1]
|
|
1141
|
+
# parse as a single CSV row
|
|
1142
|
+
row = next(csv.reader([s], skipinitialspace=True))
|
|
1143
|
+
if len(row) in (3, 4, 5):
|
|
1144
|
+
# Evaluate x,y as expressions
|
|
1145
|
+
x = _eval_expr(row[0].strip())
|
|
1146
|
+
y = _eval_expr(row[1].strip())
|
|
1147
|
+
text = row[2].strip()
|
|
1148
|
+
pos_keys = {
|
|
1149
|
+
"top-left",
|
|
1150
|
+
"top-right",
|
|
1151
|
+
"bottom-left",
|
|
1152
|
+
"bottom-right",
|
|
1153
|
+
"top-center",
|
|
1154
|
+
"bottom-center",
|
|
1155
|
+
"center-left",
|
|
1156
|
+
"center-right",
|
|
1157
|
+
}
|
|
1158
|
+
pos = "top-left"
|
|
1159
|
+
bbox_flag = False
|
|
1160
|
+
if len(row) == 4:
|
|
1161
|
+
tok = row[3].strip()
|
|
1162
|
+
if tok.lower() in pos_keys:
|
|
1163
|
+
pos = tok
|
|
1164
|
+
else:
|
|
1165
|
+
if tok.strip().lower() == "bbox":
|
|
1166
|
+
bbox_flag = True
|
|
1167
|
+
else:
|
|
1168
|
+
b = _parse_bool(tok, default=None)
|
|
1169
|
+
if isinstance(b, bool):
|
|
1170
|
+
bbox_flag = bool(b)
|
|
1171
|
+
else:
|
|
1172
|
+
# treat as position if not a boolean
|
|
1173
|
+
pos = tok
|
|
1174
|
+
elif len(row) == 5:
|
|
1175
|
+
pos = row[3].strip()
|
|
1176
|
+
tok = row[4].strip()
|
|
1177
|
+
if tok.strip().lower() == "bbox":
|
|
1178
|
+
bbox_flag = True
|
|
1179
|
+
else:
|
|
1180
|
+
b = _parse_bool(tok, default=None)
|
|
1181
|
+
if isinstance(b, bool):
|
|
1182
|
+
bbox_flag = bool(b)
|
|
1183
|
+
text_vals.append((x, y, text, pos, bbox_flag))
|
|
1184
|
+
except Exception:
|
|
1185
|
+
pass
|
|
1186
|
+
|
|
1187
|
+
# vlines: x[, ymin, ymax][, linestyle][, color] (style/color any order)
|
|
1188
|
+
vline_vals: List[Tuple[float, float | None, float | None, str | None, str | None]] = []
|
|
1189
|
+
_allowed_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
1190
|
+
for v in lists.get("vline", []):
|
|
1191
|
+
lit = _safe_literal(v)
|
|
1192
|
+
tokens: List[str] = []
|
|
1193
|
+
if isinstance(lit, (list, tuple)):
|
|
1194
|
+
tokens = [str(x).strip() for x in lit]
|
|
1195
|
+
else:
|
|
1196
|
+
tokens = [p.strip() for p in str(v).split(",") if p.strip()]
|
|
1197
|
+
|
|
1198
|
+
nums: List[float] = [] # evaluated numeric tokens (expressions allowed)
|
|
1199
|
+
extras: List[str] = [] # potential style/color tokens
|
|
1200
|
+
for t in tokens:
|
|
1201
|
+
# Attempt expression evaluation (supports arithmetic & function labels)
|
|
1202
|
+
try:
|
|
1203
|
+
val = _eval_expr(t)
|
|
1204
|
+
nums.append(val)
|
|
1205
|
+
continue
|
|
1206
|
+
except Exception:
|
|
1207
|
+
pass
|
|
1208
|
+
extras.append(t)
|
|
1209
|
+
x_val: float | None = None
|
|
1210
|
+
y0_val: float | None = None
|
|
1211
|
+
y1_val: float | None = None
|
|
1212
|
+
if len(nums) >= 1:
|
|
1213
|
+
x_val = nums[0]
|
|
1214
|
+
if len(nums) >= 3:
|
|
1215
|
+
y0_val, y1_val = nums[1], nums[2]
|
|
1216
|
+
|
|
1217
|
+
style: str | None = None
|
|
1218
|
+
color: str | None = None
|
|
1219
|
+
for e in extras:
|
|
1220
|
+
el = e.lower()
|
|
1221
|
+
if el in _allowed_styles and style is None:
|
|
1222
|
+
style = e
|
|
1223
|
+
elif color is None:
|
|
1224
|
+
color = e
|
|
1225
|
+
if x_val is not None:
|
|
1226
|
+
vline_vals.append((x_val, y0_val, y1_val, style, color))
|
|
1227
|
+
|
|
1228
|
+
# polygons: (x,y), (x,y), ... [ , show_vertices]
|
|
1229
|
+
# Extended: each coordinate may be an expression with user function calls.
|
|
1230
|
+
poly_vals: List[Tuple[List[Tuple[float, float]], bool]] = []
|
|
1231
|
+
|
|
1232
|
+
# We avoid a complex fragile regex here and instead perform a small
|
|
1233
|
+
# balanced-parentheses scan so expressions like (2*sqrt(5), f(3+pi/4)) work.
|
|
1234
|
+
def _extract_coord_pairs(seq: str) -> List[Tuple[str, str]]:
|
|
1235
|
+
pairs: List[Tuple[str, str]] = []
|
|
1236
|
+
i = 0
|
|
1237
|
+
n = len(seq)
|
|
1238
|
+
while i < n:
|
|
1239
|
+
if seq[i] == "(": # potential tuple start
|
|
1240
|
+
depth = 0
|
|
1241
|
+
j = i
|
|
1242
|
+
while j < n:
|
|
1243
|
+
ch = seq[j]
|
|
1244
|
+
if ch == "(":
|
|
1245
|
+
depth += 1
|
|
1246
|
+
elif ch == ")":
|
|
1247
|
+
depth -= 1
|
|
1248
|
+
if depth == 0:
|
|
1249
|
+
inner = seq[i + 1 : j].strip()
|
|
1250
|
+
# split inner on a top-level comma
|
|
1251
|
+
depth2 = 0
|
|
1252
|
+
comma_index = -1
|
|
1253
|
+
for k, ch2 in enumerate(inner):
|
|
1254
|
+
if ch2 == "(":
|
|
1255
|
+
depth2 += 1
|
|
1256
|
+
elif ch2 == ")":
|
|
1257
|
+
depth2 -= 1
|
|
1258
|
+
elif ch2 == "," and depth2 == 0:
|
|
1259
|
+
comma_index = k
|
|
1260
|
+
break
|
|
1261
|
+
if comma_index != -1:
|
|
1262
|
+
x_expr = inner[:comma_index].strip()
|
|
1263
|
+
y_expr = inner[comma_index + 1 :].strip()
|
|
1264
|
+
if x_expr and y_expr:
|
|
1265
|
+
pairs.append((x_expr, y_expr))
|
|
1266
|
+
i = j # jump to end of tuple
|
|
1267
|
+
break
|
|
1268
|
+
j += 1
|
|
1269
|
+
i += 1
|
|
1270
|
+
return pairs
|
|
1271
|
+
|
|
1272
|
+
for p in lists.get("polygon", []):
|
|
1273
|
+
s = str(p).strip()
|
|
1274
|
+
show_vertices = False
|
|
1275
|
+
if re.search(r"(^|,)\s*show_vertices\s*(?=,|$)", s, flags=re.IGNORECASE):
|
|
1276
|
+
show_vertices = True
|
|
1277
|
+
s = re.sub(r"(^|,)\s*show_vertices\s*(?=,|$)", ",", s, flags=re.IGNORECASE)
|
|
1278
|
+
s = re.sub(r",{2,}", ",", s).strip().strip(",")
|
|
1279
|
+
pts: List[Tuple[float, float]] = []
|
|
1280
|
+
for x_expr, y_expr in _extract_coord_pairs(s):
|
|
1281
|
+
try:
|
|
1282
|
+
xv = _eval_expr(x_expr)
|
|
1283
|
+
yv = _eval_expr(y_expr)
|
|
1284
|
+
pts.append((xv, yv))
|
|
1285
|
+
except Exception:
|
|
1286
|
+
# Ignore malformed or unevaluable pair
|
|
1287
|
+
pass
|
|
1288
|
+
if pts:
|
|
1289
|
+
poly_vals.append((pts, show_vertices))
|
|
1290
|
+
|
|
1291
|
+
# Re-introduce a plain numeric tuple matcher for other primitives still expecting numeric-only coordinates.
|
|
1292
|
+
tup_pat = re.compile(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)")
|
|
1293
|
+
|
|
1294
|
+
# bar: (x, y), length, orientation
|
|
1295
|
+
# Accept both literal list/tuple and CSV-like fallback
|
|
1296
|
+
bar_vals: List[Tuple[Tuple[float, float], float, str]] = []
|
|
1297
|
+
for b in lists.get("bar", []):
|
|
1298
|
+
lit = _safe_literal(b)
|
|
1299
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 3:
|
|
1300
|
+
try:
|
|
1301
|
+
xy_raw, length_raw, orient_raw = lit[0], lit[1], lit[2]
|
|
1302
|
+
xy = (float(xy_raw[0]), float(xy_raw[1]))
|
|
1303
|
+
length = float(length_raw)
|
|
1304
|
+
orientation = str(orient_raw).strip().lower()
|
|
1305
|
+
if orientation in {"h", "hor", "horiz", "horizontal"}:
|
|
1306
|
+
orientation = "horizontal"
|
|
1307
|
+
elif orientation in {"v", "vert", "vertical"}:
|
|
1308
|
+
orientation = "vertical"
|
|
1309
|
+
bar_vals.append((xy, length, orientation))
|
|
1310
|
+
continue
|
|
1311
|
+
except Exception:
|
|
1312
|
+
pass
|
|
1313
|
+
# Fallback CSV: (x,y), length, orientation
|
|
1314
|
+
try:
|
|
1315
|
+
import csv as _csv
|
|
1316
|
+
|
|
1317
|
+
s = str(b).strip()
|
|
1318
|
+
# Attempt to peel off the first tuple
|
|
1319
|
+
m = tup_pat.search(s)
|
|
1320
|
+
if not m:
|
|
1321
|
+
continue
|
|
1322
|
+
x = float(m.group(1))
|
|
1323
|
+
y = float(m.group(2))
|
|
1324
|
+
# Remove tuple substring, then split the rest by commas
|
|
1325
|
+
a, c = m.span()
|
|
1326
|
+
rest = (s[:a] + s[c:]).strip()
|
|
1327
|
+
parts = [p.strip() for p in rest.split(",") if p.strip()]
|
|
1328
|
+
if len(parts) >= 2:
|
|
1329
|
+
length = float(parts[0])
|
|
1330
|
+
orientation = parts[1].strip().lower()
|
|
1331
|
+
if orientation in {"h", "hor", "horiz", "horizontal"}:
|
|
1332
|
+
orientation = "horizontal"
|
|
1333
|
+
elif orientation in {"v", "vert", "vertical"}:
|
|
1334
|
+
orientation = "vertical"
|
|
1335
|
+
bar_vals.append(((x, y), length, orientation))
|
|
1336
|
+
except Exception:
|
|
1337
|
+
pass
|
|
1338
|
+
|
|
1339
|
+
hline_vals: List[Tuple[float, float | None, float | None, str | None, str | None]] = []
|
|
1340
|
+
for h in lists.get("hline", []):
|
|
1341
|
+
lit = _safe_literal(h)
|
|
1342
|
+
tokens_h: List[str] = []
|
|
1343
|
+
if isinstance(lit, (list, tuple)):
|
|
1344
|
+
tokens_h = [str(x).strip() for x in lit]
|
|
1345
|
+
else:
|
|
1346
|
+
tokens_h = [p.strip() for p in str(h).split(",") if p.strip()]
|
|
1347
|
+
|
|
1348
|
+
nums_h: List[float] = [] # numeric (expressions) for y, x0, x1
|
|
1349
|
+
extras_h: List[str] = [] # style/color tokens
|
|
1350
|
+
for t in tokens_h:
|
|
1351
|
+
try:
|
|
1352
|
+
val = _eval_expr(t)
|
|
1353
|
+
nums_h.append(val)
|
|
1354
|
+
continue
|
|
1355
|
+
except Exception:
|
|
1356
|
+
pass
|
|
1357
|
+
extras_h.append(t)
|
|
1358
|
+
y_val: float | None = None
|
|
1359
|
+
x0_val: float | None = None
|
|
1360
|
+
x1_val: float | None = None
|
|
1361
|
+
if len(nums_h) >= 1:
|
|
1362
|
+
y_val = nums_h[0]
|
|
1363
|
+
if len(nums_h) >= 3:
|
|
1364
|
+
x0_val, x1_val = nums_h[1], nums_h[2]
|
|
1365
|
+
|
|
1366
|
+
style_h: str | None = None
|
|
1367
|
+
color_h: str | None = None
|
|
1368
|
+
for e in extras_h:
|
|
1369
|
+
el = e.lower()
|
|
1370
|
+
if el in _allowed_styles and style_h is None:
|
|
1371
|
+
style_h = e
|
|
1372
|
+
elif color_h is None:
|
|
1373
|
+
color_h = e
|
|
1374
|
+
if y_val is not None:
|
|
1375
|
+
hline_vals.append((y_val, x0_val, x1_val, style_h, color_h))
|
|
1376
|
+
|
|
1377
|
+
# lines: a, b, color, linestyle OR a, (x, y), color, linestyle
|
|
1378
|
+
# color and linestyle optional and order-independent; linestyle defaults to dashed
|
|
1379
|
+
line_vals: List[Tuple[float, float, str | None, str | None]] = [] # (a, b, style, color)
|
|
1380
|
+
_allowed_styles_line = {"solid", "dotted", "dashed", "dashdot"}
|
|
1381
|
+
|
|
1382
|
+
def _split_top_level_line(val: str) -> List[str]:
|
|
1383
|
+
s = str(val or "").strip()
|
|
1384
|
+
if not s:
|
|
1385
|
+
return []
|
|
1386
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
1387
|
+
s = s[1:-1].strip()
|
|
1388
|
+
out: List[str] = []
|
|
1389
|
+
cur: List[str] = []
|
|
1390
|
+
depth = 0
|
|
1391
|
+
for ch in s:
|
|
1392
|
+
if ch in "([{":
|
|
1393
|
+
depth += 1
|
|
1394
|
+
cur.append(ch)
|
|
1395
|
+
elif ch in ")]}":
|
|
1396
|
+
depth = max(0, depth - 1)
|
|
1397
|
+
cur.append(ch)
|
|
1398
|
+
elif ch == "," and depth == 0:
|
|
1399
|
+
part = "".join(cur).strip()
|
|
1400
|
+
if part:
|
|
1401
|
+
out.append(part)
|
|
1402
|
+
cur = []
|
|
1403
|
+
else:
|
|
1404
|
+
cur.append(ch)
|
|
1405
|
+
tail = "".join(cur).strip()
|
|
1406
|
+
if tail:
|
|
1407
|
+
out.append(tail)
|
|
1408
|
+
return out
|
|
1409
|
+
|
|
1410
|
+
num_re_line = r"[+-]?\d+(?:\.\d+)?"
|
|
1411
|
+
tup_pat_line = re.compile(rf"\(\s*({num_re_line})\s*,\s*({num_re_line})\s*\)")
|
|
1412
|
+
for l in lists.get("line", []):
|
|
1413
|
+
a_val: float | None = None
|
|
1414
|
+
b_val: float | None = None
|
|
1415
|
+
style_line: str | None = None
|
|
1416
|
+
color_line: str | None = None
|
|
1417
|
+
lit_line = _safe_literal(l)
|
|
1418
|
+
if isinstance(lit_line, (list, tuple)) and len(lit_line) >= 2:
|
|
1419
|
+
try:
|
|
1420
|
+
a_val = float(lit_line[0])
|
|
1421
|
+
except Exception:
|
|
1422
|
+
a_val = None
|
|
1423
|
+
second = lit_line[1]
|
|
1424
|
+
if isinstance(second, (list, tuple)) and len(second) == 2:
|
|
1425
|
+
try:
|
|
1426
|
+
x0p = float(second[0])
|
|
1427
|
+
y0p = float(second[1])
|
|
1428
|
+
if a_val is not None:
|
|
1429
|
+
b_val = y0p - a_val * x0p
|
|
1430
|
+
except Exception:
|
|
1431
|
+
pass
|
|
1432
|
+
else:
|
|
1433
|
+
try:
|
|
1434
|
+
b_val = float(second)
|
|
1435
|
+
except Exception:
|
|
1436
|
+
pass
|
|
1437
|
+
for extra in list(lit_line[2:]):
|
|
1438
|
+
if isinstance(extra, str):
|
|
1439
|
+
e = extra.strip().strip("\"'")
|
|
1440
|
+
if e.lower() in _allowed_styles_line and style_line is None:
|
|
1441
|
+
style_line = e.lower()
|
|
1442
|
+
elif color_line is None:
|
|
1443
|
+
color_line = e
|
|
1444
|
+
if a_val is not None and b_val is not None:
|
|
1445
|
+
line_vals.append((a_val, b_val, style_line, color_line))
|
|
1446
|
+
continue
|
|
1447
|
+
parts = _split_top_level_line(str(l))
|
|
1448
|
+
if len(parts) >= 2:
|
|
1449
|
+
try:
|
|
1450
|
+
a_val = float(parts[0])
|
|
1451
|
+
except Exception:
|
|
1452
|
+
a_val = None
|
|
1453
|
+
mpt = tup_pat_line.match(parts[1])
|
|
1454
|
+
if mpt is not None:
|
|
1455
|
+
try:
|
|
1456
|
+
x0p = float(mpt.group(1))
|
|
1457
|
+
y0p = float(mpt.group(2))
|
|
1458
|
+
if a_val is not None:
|
|
1459
|
+
b_val = y0p - a_val * x0p
|
|
1460
|
+
except Exception:
|
|
1461
|
+
pass
|
|
1462
|
+
else:
|
|
1463
|
+
try:
|
|
1464
|
+
b_val = float(parts[1])
|
|
1465
|
+
except Exception:
|
|
1466
|
+
pass
|
|
1467
|
+
for extra in parts[2:]:
|
|
1468
|
+
e = str(extra).strip().strip("\"'")
|
|
1469
|
+
if e.lower() in _allowed_styles_line and style_line is None:
|
|
1470
|
+
style_line = e.lower()
|
|
1471
|
+
elif color_line is None:
|
|
1472
|
+
color_line = e
|
|
1473
|
+
if a_val is not None and b_val is not None:
|
|
1474
|
+
line_vals.append((a_val, b_val, style_line, color_line))
|
|
1475
|
+
|
|
1476
|
+
# fill-polygons: (x,y), (x,y), ... [, color] [, alpha]
|
|
1477
|
+
# Extended: coordinate expressions support arithmetic & function calls like polygons.
|
|
1478
|
+
# Defaults: color -> plotmath.COLORS.get("blue"), alpha -> 0.1
|
|
1479
|
+
poly_fill_vals: List[Tuple[List[Tuple[float, float]], str | None, float | None]] = []
|
|
1480
|
+
for fp in lists.get("fill-polygon", []):
|
|
1481
|
+
s = str(fp).strip()
|
|
1482
|
+
pts_fp: List[Tuple[float, float]] = []
|
|
1483
|
+
# Reuse polygon balanced-parentheses extractor defined earlier.
|
|
1484
|
+
# We reconstruct remaining extras by removing the matched tuple spans.
|
|
1485
|
+
consumed: List[Tuple[int, int]] = []
|
|
1486
|
+
for x_expr, y_expr in _extract_coord_pairs(s):
|
|
1487
|
+
# Find the substring to mark as consumed (naively search first occurrence of '(' + content + ')')
|
|
1488
|
+
pattern = f"({x_expr},{y_expr})" # simplified; if spaces existed they were stripped
|
|
1489
|
+
# We'll just evaluate; for extras we approximate removal by replacing once later.
|
|
1490
|
+
try:
|
|
1491
|
+
xv = _eval_expr(x_expr)
|
|
1492
|
+
yv = _eval_expr(y_expr)
|
|
1493
|
+
pts_fp.append((xv, yv))
|
|
1494
|
+
except Exception:
|
|
1495
|
+
pass
|
|
1496
|
+
# Remove coordinate tuples crudely: replace occurrences of '(...)' that correspond to points
|
|
1497
|
+
rest = s
|
|
1498
|
+
# Simple loop removing parenthesized pairs counted earlier; risk: may remove unrelated parentheses if same text repeats
|
|
1499
|
+
# but acceptable for directive usage.
|
|
1500
|
+
# A safer approach would replicate extraction with span tracking; to keep patch minimal we do a regex wipe of tuples.
|
|
1501
|
+
rest = re.sub(r"\([^()]*?,[^()]*?\)", "", rest)
|
|
1502
|
+
rest = re.sub(r",{2,}", ",", rest)
|
|
1503
|
+
extras = [tok.strip() for tok in rest.split(",") if tok.strip()]
|
|
1504
|
+
color_fp: str | None = None
|
|
1505
|
+
alpha_fp: float | None = None
|
|
1506
|
+
# Interpret extras in any order: first numeric -> alpha, first non-numeric -> color
|
|
1507
|
+
for tok in extras:
|
|
1508
|
+
if alpha_fp is None:
|
|
1509
|
+
try:
|
|
1510
|
+
alpha_fp = _eval_expr(tok)
|
|
1511
|
+
continue
|
|
1512
|
+
except Exception:
|
|
1513
|
+
pass
|
|
1514
|
+
if color_fp is None:
|
|
1515
|
+
color_fp = tok
|
|
1516
|
+
# Stop early if both parsed
|
|
1517
|
+
if color_fp is not None and alpha_fp is not None:
|
|
1518
|
+
break
|
|
1519
|
+
if pts_fp:
|
|
1520
|
+
poly_fill_vals.append((pts_fp, color_fp, alpha_fp))
|
|
1521
|
+
|
|
1522
|
+
# line-segment: (x1,y1), (x2,y2)[, linestyle][, color] (style/color optional, any order)
|
|
1523
|
+
line_segment_vals: List[
|
|
1524
|
+
Tuple[Tuple[float, float], Tuple[float, float], str | None, str | None]
|
|
1525
|
+
] = []
|
|
1526
|
+
_allowed_seg_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
1527
|
+
for ls in lists.get("line-segment", []):
|
|
1528
|
+
s = str(ls).strip()
|
|
1529
|
+
# Use the same balanced extractor as polygons but ensure exactly two points are taken.
|
|
1530
|
+
pairs = _extract_coord_pairs(s)
|
|
1531
|
+
if len(pairs) < 2:
|
|
1532
|
+
continue
|
|
1533
|
+
pcoords: List[Tuple[float, float]] = []
|
|
1534
|
+
for x_expr, y_expr in pairs[:2]:
|
|
1535
|
+
try:
|
|
1536
|
+
xv = _eval_expr(x_expr)
|
|
1537
|
+
yv = _eval_expr(y_expr)
|
|
1538
|
+
pcoords.append((float(xv), float(yv)))
|
|
1539
|
+
except Exception:
|
|
1540
|
+
pcoords = []
|
|
1541
|
+
break
|
|
1542
|
+
if len(pcoords) != 2:
|
|
1543
|
+
continue
|
|
1544
|
+
# Precisely remove the first two top-level tuples (with balanced parentheses)
|
|
1545
|
+
spans: List[Tuple[int, int]] = []
|
|
1546
|
+
depth = 0
|
|
1547
|
+
i = 0
|
|
1548
|
+
n = len(s)
|
|
1549
|
+
while i < n and len(spans) < 2:
|
|
1550
|
+
if s[i] == "(":
|
|
1551
|
+
depth = 1
|
|
1552
|
+
j = i + 1
|
|
1553
|
+
while j < n and depth > 0:
|
|
1554
|
+
if s[j] == "(":
|
|
1555
|
+
depth += 1
|
|
1556
|
+
elif s[j] == ")":
|
|
1557
|
+
depth -= 1
|
|
1558
|
+
j += 1
|
|
1559
|
+
if depth == 0:
|
|
1560
|
+
# captured tuple from i to j
|
|
1561
|
+
inner = s[i + 1 : j - 1]
|
|
1562
|
+
# verify it contains a top-level comma (treat as coordinate tuple)
|
|
1563
|
+
d2 = 0
|
|
1564
|
+
has_comma = False
|
|
1565
|
+
for ch in inner:
|
|
1566
|
+
if ch == "(":
|
|
1567
|
+
d2 += 1
|
|
1568
|
+
elif ch == ")":
|
|
1569
|
+
d2 -= 1
|
|
1570
|
+
elif ch == "," and d2 == 0:
|
|
1571
|
+
has_comma = True
|
|
1572
|
+
break
|
|
1573
|
+
if has_comma:
|
|
1574
|
+
spans.append((i, j))
|
|
1575
|
+
i = j
|
|
1576
|
+
else:
|
|
1577
|
+
i += 1
|
|
1578
|
+
# Build rest excluding spans
|
|
1579
|
+
if spans:
|
|
1580
|
+
parts_rest: List[str] = []
|
|
1581
|
+
last = 0
|
|
1582
|
+
for a, b in spans:
|
|
1583
|
+
if a > last:
|
|
1584
|
+
parts_rest.append(s[last:a])
|
|
1585
|
+
last = b
|
|
1586
|
+
if last < len(s):
|
|
1587
|
+
parts_rest.append(s[last:])
|
|
1588
|
+
rest = "".join(parts_rest)
|
|
1589
|
+
else:
|
|
1590
|
+
rest = s
|
|
1591
|
+
rest = re.sub(r",{2,}", ",", rest)
|
|
1592
|
+
tokens = [tok.strip().strip("'\"") for tok in rest.split(",") if tok.strip()]
|
|
1593
|
+
style_seg: str | None = None
|
|
1594
|
+
color_seg: str | None = None
|
|
1595
|
+
for tok in tokens:
|
|
1596
|
+
low = tok.lower()
|
|
1597
|
+
if low in _allowed_seg_styles and style_seg is None:
|
|
1598
|
+
style_seg = low
|
|
1599
|
+
continue
|
|
1600
|
+
if color_seg is None:
|
|
1601
|
+
# Accept token as color (will map later during draw)
|
|
1602
|
+
color_seg = tok
|
|
1603
|
+
line_segment_vals.append((pcoords[0], pcoords[1], style_seg, color_seg))
|
|
1604
|
+
|
|
1605
|
+
# axis commands: allow repeated keys like axis: equal / axis: off
|
|
1606
|
+
axis_cmds: List[str] = []
|
|
1607
|
+
for a in lists.get("axis", []):
|
|
1608
|
+
s = str(a).strip()
|
|
1609
|
+
# Allow comma-separated in one line as a convenience
|
|
1610
|
+
parts = [part.strip() for part in s.split(",") if part.strip()]
|
|
1611
|
+
for part in parts:
|
|
1612
|
+
# strip optional quotes
|
|
1613
|
+
if (part.startswith("'") and part.endswith("'")) or (
|
|
1614
|
+
part.startswith('"') and part.endswith('"')
|
|
1615
|
+
):
|
|
1616
|
+
part = part[1:-1].strip()
|
|
1617
|
+
if part:
|
|
1618
|
+
axis_cmds.append(part)
|
|
1619
|
+
|
|
1620
|
+
# vectors: x, y, dx, dy[, color] with expression support
|
|
1621
|
+
vector_vals: List[Tuple[float, float, float, float, str]] = []
|
|
1622
|
+
for vline in lists.get("vector", []):
|
|
1623
|
+
s = str(vline).strip()
|
|
1624
|
+
# allow surrounding brackets/parentheses
|
|
1625
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
1626
|
+
s = s[1:-1].strip()
|
|
1627
|
+
# split by top-level commas (vectors unlikely to embed parentheses beyond simple expressions, but keep safe)
|
|
1628
|
+
depth_vec = 0
|
|
1629
|
+
cur_tok: List[str] = []
|
|
1630
|
+
parts: List[str] = []
|
|
1631
|
+
for ch in s:
|
|
1632
|
+
if ch == "(":
|
|
1633
|
+
depth_vec += 1
|
|
1634
|
+
cur_tok.append(ch)
|
|
1635
|
+
elif ch == ")":
|
|
1636
|
+
depth_vec -= 1
|
|
1637
|
+
cur_tok.append(ch)
|
|
1638
|
+
elif ch == "," and depth_vec == 0:
|
|
1639
|
+
tok = "".join(cur_tok).strip()
|
|
1640
|
+
if tok:
|
|
1641
|
+
parts.append(tok)
|
|
1642
|
+
cur_tok = []
|
|
1643
|
+
else:
|
|
1644
|
+
cur_tok.append(ch)
|
|
1645
|
+
tail_tok = "".join(cur_tok).strip()
|
|
1646
|
+
if tail_tok:
|
|
1647
|
+
parts.append(tail_tok)
|
|
1648
|
+
if len(parts) < 4:
|
|
1649
|
+
continue
|
|
1650
|
+
try:
|
|
1651
|
+
x_v = float(_eval_expr(parts[0]))
|
|
1652
|
+
y_v = float(_eval_expr(parts[1]))
|
|
1653
|
+
dx_v = float(_eval_expr(parts[2]))
|
|
1654
|
+
dy_v = float(_eval_expr(parts[3]))
|
|
1655
|
+
color_v = parts[4] if len(parts) >= 5 and parts[4] else "black"
|
|
1656
|
+
vector_vals.append((x_v, y_v, dx_v, dy_v, color_v))
|
|
1657
|
+
except Exception:
|
|
1658
|
+
# skip silently to preserve robustness
|
|
1659
|
+
continue
|
|
1660
|
+
|
|
1661
|
+
# angle-arc: (x, y), radius, start_angle_deg, end_angle_deg[, linestyle][, color]
|
|
1662
|
+
# Expression support for center, radius and angles; optional linestyle/color tokens in any order after the first three numeric expressions.
|
|
1663
|
+
angle_arcs: List[Tuple[float, float, float, float, float, str | None, str | None]] = []
|
|
1664
|
+
_allowed_arc_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
1665
|
+
for arc in lists.get("angle-arc", []):
|
|
1666
|
+
raw_arc = str(arc).strip()
|
|
1667
|
+
# Find first balanced parenthesis group for center
|
|
1668
|
+
idx_arc = raw_arc.find("(")
|
|
1669
|
+
if idx_arc == -1:
|
|
1670
|
+
continue
|
|
1671
|
+
depth_arc = 0
|
|
1672
|
+
end_center = -1
|
|
1673
|
+
for j in range(idx_arc, len(raw_arc)):
|
|
1674
|
+
ch = raw_arc[j]
|
|
1675
|
+
if ch == "(":
|
|
1676
|
+
depth_arc += 1
|
|
1677
|
+
elif ch == ")":
|
|
1678
|
+
depth_arc -= 1
|
|
1679
|
+
if depth_arc == 0:
|
|
1680
|
+
end_center = j
|
|
1681
|
+
break
|
|
1682
|
+
if end_center == -1:
|
|
1683
|
+
continue
|
|
1684
|
+
center_inner = raw_arc[idx_arc + 1 : end_center]
|
|
1685
|
+
# Split center_inner by top-level comma
|
|
1686
|
+
depth_c = 0
|
|
1687
|
+
cur_bits: List[str] = []
|
|
1688
|
+
center_parts: List[str] = []
|
|
1689
|
+
for ch in center_inner:
|
|
1690
|
+
if ch == "(":
|
|
1691
|
+
depth_c += 1
|
|
1692
|
+
cur_bits.append(ch)
|
|
1693
|
+
elif ch == ")":
|
|
1694
|
+
depth_c -= 1
|
|
1695
|
+
cur_bits.append(ch)
|
|
1696
|
+
elif ch == "," and depth_c == 0:
|
|
1697
|
+
token = "".join(cur_bits).strip()
|
|
1698
|
+
if token:
|
|
1699
|
+
center_parts.append(token)
|
|
1700
|
+
cur_bits = []
|
|
1701
|
+
else:
|
|
1702
|
+
cur_bits.append(ch)
|
|
1703
|
+
tail_cp = "".join(cur_bits).strip()
|
|
1704
|
+
if tail_cp:
|
|
1705
|
+
center_parts.append(tail_cp)
|
|
1706
|
+
if len(center_parts) != 2:
|
|
1707
|
+
continue
|
|
1708
|
+
rest_arc = raw_arc[end_center + 1 :].lstrip(",").strip()
|
|
1709
|
+
if not rest_arc:
|
|
1710
|
+
continue
|
|
1711
|
+
# Split rest by top-level commas
|
|
1712
|
+
depth_r = 0
|
|
1713
|
+
cur_r: List[str] = []
|
|
1714
|
+
tokens_r: List[str] = []
|
|
1715
|
+
for ch in rest_arc:
|
|
1716
|
+
if ch == "(":
|
|
1717
|
+
depth_r += 1
|
|
1718
|
+
cur_r.append(ch)
|
|
1719
|
+
elif ch == ")":
|
|
1720
|
+
depth_r -= 1
|
|
1721
|
+
cur_r.append(ch)
|
|
1722
|
+
elif ch == "," and depth_r == 0:
|
|
1723
|
+
tk = "".join(cur_r).strip()
|
|
1724
|
+
if tk:
|
|
1725
|
+
tokens_r.append(tk)
|
|
1726
|
+
cur_r = []
|
|
1727
|
+
else:
|
|
1728
|
+
cur_r.append(ch)
|
|
1729
|
+
tail_r = "".join(cur_r).strip()
|
|
1730
|
+
if tail_r:
|
|
1731
|
+
tokens_r.append(tail_r)
|
|
1732
|
+
if len(tokens_r) < 3:
|
|
1733
|
+
continue
|
|
1734
|
+
radius_expr = tokens_r[0]
|
|
1735
|
+
start_expr = tokens_r[1]
|
|
1736
|
+
end_expr = tokens_r[2]
|
|
1737
|
+
style_arc: str | None = None
|
|
1738
|
+
color_arc: str | None = None
|
|
1739
|
+
for extra_tok in tokens_r[3:]:
|
|
1740
|
+
low = extra_tok.lower()
|
|
1741
|
+
if low in _allowed_arc_styles and style_arc is None:
|
|
1742
|
+
style_arc = low
|
|
1743
|
+
elif color_arc is None:
|
|
1744
|
+
color_arc = extra_tok
|
|
1745
|
+
try:
|
|
1746
|
+
cx_val = float(_eval_expr(center_parts[0]))
|
|
1747
|
+
cy_val = float(_eval_expr(center_parts[1]))
|
|
1748
|
+
r_val = float(_eval_expr(radius_expr))
|
|
1749
|
+
if r_val <= 0:
|
|
1750
|
+
continue
|
|
1751
|
+
start_deg_val = float(_eval_expr(start_expr))
|
|
1752
|
+
end_deg_val = float(_eval_expr(end_expr))
|
|
1753
|
+
angle_arcs.append(
|
|
1754
|
+
(
|
|
1755
|
+
cx_val,
|
|
1756
|
+
cy_val,
|
|
1757
|
+
r_val,
|
|
1758
|
+
start_deg_val,
|
|
1759
|
+
end_deg_val,
|
|
1760
|
+
style_arc,
|
|
1761
|
+
color_arc,
|
|
1762
|
+
)
|
|
1763
|
+
)
|
|
1764
|
+
except Exception:
|
|
1765
|
+
continue
|
|
1766
|
+
|
|
1767
|
+
# circles: (x,y), radius[, linestyle][, color] (style/color optional, any order)
|
|
1768
|
+
# Accept expressions for x, y, radius. Optional tokens may appear in any order
|
|
1769
|
+
# after the radius token. Supported linestyles: solid, dotted, dashed, dashdot.
|
|
1770
|
+
circle_vals: List[Tuple[float, float, float, str | None, str | None]] = []
|
|
1771
|
+
_allowed_circle_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
1772
|
+
for c in lists.get("circle", []):
|
|
1773
|
+
raw = str(c).strip()
|
|
1774
|
+
# Expect something like: (expr_x, expr_y), radius_expr
|
|
1775
|
+
# We'll find first balanced tuple then split remaining by comma for radius.
|
|
1776
|
+
idx = raw.find("(")
|
|
1777
|
+
if idx == -1:
|
|
1778
|
+
continue
|
|
1779
|
+
# Grab balanced tuple
|
|
1780
|
+
depth = 0
|
|
1781
|
+
end_idx = -1
|
|
1782
|
+
for j in range(idx, len(raw)):
|
|
1783
|
+
if raw[j] == "(":
|
|
1784
|
+
depth += 1
|
|
1785
|
+
elif raw[j] == ")":
|
|
1786
|
+
depth -= 1
|
|
1787
|
+
if depth == 0:
|
|
1788
|
+
end_idx = j
|
|
1789
|
+
break
|
|
1790
|
+
if end_idx == -1:
|
|
1791
|
+
continue
|
|
1792
|
+
inner = raw[idx + 1 : end_idx]
|
|
1793
|
+
# split inner into x,y
|
|
1794
|
+
depth2 = 0
|
|
1795
|
+
comma_i = -1
|
|
1796
|
+
for k, ch in enumerate(inner):
|
|
1797
|
+
if ch == "(":
|
|
1798
|
+
depth2 += 1
|
|
1799
|
+
elif ch == ")":
|
|
1800
|
+
depth2 -= 1
|
|
1801
|
+
elif ch == "," and depth2 == 0:
|
|
1802
|
+
comma_i = k
|
|
1803
|
+
break
|
|
1804
|
+
if comma_i == -1:
|
|
1805
|
+
continue
|
|
1806
|
+
x_expr = inner[:comma_i].strip()
|
|
1807
|
+
y_expr = inner[comma_i + 1 :].strip()
|
|
1808
|
+
# Remaining after tuple for radius
|
|
1809
|
+
rest = raw[end_idx + 1 :].strip().lstrip(",").strip()
|
|
1810
|
+
if not rest:
|
|
1811
|
+
continue
|
|
1812
|
+
# Split rest into top-level comma tokens (radius + optional style/color)
|
|
1813
|
+
depth3 = 0
|
|
1814
|
+
tokens: List[str] = []
|
|
1815
|
+
cur: List[str] = []
|
|
1816
|
+
for ch in rest:
|
|
1817
|
+
if ch == "(":
|
|
1818
|
+
depth3 += 1
|
|
1819
|
+
cur.append(ch)
|
|
1820
|
+
elif ch == ")":
|
|
1821
|
+
depth3 -= 1
|
|
1822
|
+
cur.append(ch)
|
|
1823
|
+
elif ch == "," and depth3 == 0:
|
|
1824
|
+
part = "".join(cur).strip()
|
|
1825
|
+
if part:
|
|
1826
|
+
tokens.append(part)
|
|
1827
|
+
cur = []
|
|
1828
|
+
else:
|
|
1829
|
+
cur.append(ch)
|
|
1830
|
+
tail = "".join(cur).strip()
|
|
1831
|
+
if tail:
|
|
1832
|
+
tokens.append(tail)
|
|
1833
|
+
if not tokens:
|
|
1834
|
+
continue
|
|
1835
|
+
r_token = tokens[0]
|
|
1836
|
+
style_circle: str | None = None
|
|
1837
|
+
color_circle: str | None = None
|
|
1838
|
+
for tok in tokens[1:]:
|
|
1839
|
+
low = tok.lower()
|
|
1840
|
+
if low in _allowed_circle_styles and style_circle is None:
|
|
1841
|
+
style_circle = low
|
|
1842
|
+
elif color_circle is None:
|
|
1843
|
+
color_circle = tok
|
|
1844
|
+
try:
|
|
1845
|
+
xv = _eval_expr(x_expr)
|
|
1846
|
+
yv = _eval_expr(y_expr)
|
|
1847
|
+
rv = _eval_expr(r_token)
|
|
1848
|
+
if rv <= 0:
|
|
1849
|
+
continue
|
|
1850
|
+
circle_vals.append((float(xv), float(yv), float(rv), style_circle, color_circle))
|
|
1851
|
+
except Exception:
|
|
1852
|
+
# Silently skip invalid circle
|
|
1853
|
+
pass
|
|
1854
|
+
|
|
1855
|
+
# ellipses: (x0,y0), a, b[, linestyle][, color]
|
|
1856
|
+
# Parameterization: x = x0 + a*cos(t), y = y0 + b*sin(t), t in [0, 2*pi]
|
|
1857
|
+
ellipse_vals: List[Tuple[float, float, float, float, str | None, str | None]] = []
|
|
1858
|
+
_allowed_ellipse_styles = _allowed_circle_styles
|
|
1859
|
+
for e in lists.get("ellipse", []):
|
|
1860
|
+
raw = str(e).strip()
|
|
1861
|
+
idx = raw.find("(")
|
|
1862
|
+
if idx == -1:
|
|
1863
|
+
continue
|
|
1864
|
+
depth = 0
|
|
1865
|
+
end_idx = -1
|
|
1866
|
+
for j in range(idx, len(raw)):
|
|
1867
|
+
if raw[j] == "(":
|
|
1868
|
+
depth += 1
|
|
1869
|
+
elif raw[j] == ")":
|
|
1870
|
+
depth -= 1
|
|
1871
|
+
if depth == 0:
|
|
1872
|
+
end_idx = j
|
|
1873
|
+
break
|
|
1874
|
+
if end_idx == -1:
|
|
1875
|
+
continue
|
|
1876
|
+
inner = raw[idx + 1 : end_idx]
|
|
1877
|
+
# split inner center on top-level comma
|
|
1878
|
+
depth2 = 0
|
|
1879
|
+
comma_i = -1
|
|
1880
|
+
for k, ch in enumerate(inner):
|
|
1881
|
+
if ch == "(":
|
|
1882
|
+
depth2 += 1
|
|
1883
|
+
elif ch == ")":
|
|
1884
|
+
depth2 -= 1
|
|
1885
|
+
elif ch == "," and depth2 == 0:
|
|
1886
|
+
comma_i = k
|
|
1887
|
+
break
|
|
1888
|
+
if comma_i == -1:
|
|
1889
|
+
continue
|
|
1890
|
+
x0_expr = inner[:comma_i].strip()
|
|
1891
|
+
y0_expr = inner[comma_i + 1 :].strip()
|
|
1892
|
+
rest = raw[end_idx + 1 :].strip().lstrip(",").strip()
|
|
1893
|
+
if not rest:
|
|
1894
|
+
continue
|
|
1895
|
+
# tokenize rest top-level commas
|
|
1896
|
+
depth3 = 0
|
|
1897
|
+
tokens: List[str] = []
|
|
1898
|
+
cur: List[str] = []
|
|
1899
|
+
for ch in rest:
|
|
1900
|
+
if ch == "(":
|
|
1901
|
+
depth3 += 1
|
|
1902
|
+
cur.append(ch)
|
|
1903
|
+
elif ch == ")":
|
|
1904
|
+
depth3 -= 1
|
|
1905
|
+
cur.append(ch)
|
|
1906
|
+
elif ch == "," and depth3 == 0:
|
|
1907
|
+
part = "".join(cur).strip()
|
|
1908
|
+
if part:
|
|
1909
|
+
tokens.append(part)
|
|
1910
|
+
cur = []
|
|
1911
|
+
else:
|
|
1912
|
+
cur.append(ch)
|
|
1913
|
+
tail = "".join(cur).strip()
|
|
1914
|
+
if tail:
|
|
1915
|
+
tokens.append(tail)
|
|
1916
|
+
if len(tokens) < 2: # need a and b at least
|
|
1917
|
+
continue
|
|
1918
|
+
a_expr = tokens[0]
|
|
1919
|
+
b_expr = tokens[1]
|
|
1920
|
+
style_e: str | None = None
|
|
1921
|
+
color_e: str | None = None
|
|
1922
|
+
for tok in tokens[2:]:
|
|
1923
|
+
low = tok.lower()
|
|
1924
|
+
if low in _allowed_ellipse_styles and style_e is None:
|
|
1925
|
+
style_e = low
|
|
1926
|
+
elif color_e is None:
|
|
1927
|
+
color_e = tok
|
|
1928
|
+
try:
|
|
1929
|
+
x0v = _eval_expr(x0_expr)
|
|
1930
|
+
y0v = _eval_expr(y0_expr)
|
|
1931
|
+
av = _eval_expr(a_expr)
|
|
1932
|
+
bv = _eval_expr(b_expr)
|
|
1933
|
+
if av <= 0 or bv <= 0:
|
|
1934
|
+
continue
|
|
1935
|
+
ellipse_vals.append(
|
|
1936
|
+
(float(x0v), float(y0v), float(av), float(bv), style_e, color_e)
|
|
1937
|
+
)
|
|
1938
|
+
except Exception:
|
|
1939
|
+
pass
|
|
1940
|
+
|
|
1941
|
+
explicit_name = merged.get("name")
|
|
1942
|
+
debug_mode = "debug" in merged
|
|
1943
|
+
# curves: x_expr, y_expr, (t_start, t_end)[, linestyle][, color]
|
|
1944
|
+
curve_specs: List[Tuple[str, str, float, float, str | None, str | None]] = []
|
|
1945
|
+
_allowed_curve_styles = {"solid", "dotted", "dashed", "dashdot"}
|
|
1946
|
+
for c_line in lists.get("curve", []):
|
|
1947
|
+
s_line = str(c_line).strip()
|
|
1948
|
+
# Split top-level commas
|
|
1949
|
+
depth_c = 0
|
|
1950
|
+
parts_c: List[str] = []
|
|
1951
|
+
cur_c: List[str] = []
|
|
1952
|
+
for ch in s_line:
|
|
1953
|
+
if ch == "(":
|
|
1954
|
+
depth_c += 1
|
|
1955
|
+
cur_c.append(ch)
|
|
1956
|
+
elif ch == ")":
|
|
1957
|
+
depth_c -= 1
|
|
1958
|
+
cur_c.append(ch)
|
|
1959
|
+
elif ch == "," and depth_c == 0:
|
|
1960
|
+
token = "".join(cur_c).strip()
|
|
1961
|
+
if token:
|
|
1962
|
+
parts_c.append(token)
|
|
1963
|
+
cur_c = []
|
|
1964
|
+
else:
|
|
1965
|
+
cur_c.append(ch)
|
|
1966
|
+
tail_c = "".join(cur_c).strip()
|
|
1967
|
+
if tail_c:
|
|
1968
|
+
parts_c.append(tail_c)
|
|
1969
|
+
if len(parts_c) < 3:
|
|
1970
|
+
continue
|
|
1971
|
+
x_expr_c = parts_c[0]
|
|
1972
|
+
y_expr_c = parts_c[1]
|
|
1973
|
+
interval_token = parts_c[2]
|
|
1974
|
+
m_iv = re.match(r"^\(\s*(.+?)\s*,\s*(.+?)\s*\)$", interval_token)
|
|
1975
|
+
if not m_iv:
|
|
1976
|
+
continue
|
|
1977
|
+
t0_expr = m_iv.group(1)
|
|
1978
|
+
t1_expr = m_iv.group(2)
|
|
1979
|
+
style_cur: str | None = None
|
|
1980
|
+
color_cur: str | None = None
|
|
1981
|
+
for tok in parts_c[3:]:
|
|
1982
|
+
low = tok.lower()
|
|
1983
|
+
if low in _allowed_curve_styles and style_cur is None:
|
|
1984
|
+
style_cur = low
|
|
1985
|
+
elif color_cur is None:
|
|
1986
|
+
color_cur = tok
|
|
1987
|
+
try:
|
|
1988
|
+
t0_val = _eval_expr(t0_expr)
|
|
1989
|
+
t1_val = _eval_expr(t1_expr)
|
|
1990
|
+
if t1_val < t0_val:
|
|
1991
|
+
t0_val, t1_val = t1_val, t0_val
|
|
1992
|
+
curve_specs.append(
|
|
1993
|
+
(
|
|
1994
|
+
x_expr_c,
|
|
1995
|
+
y_expr_c,
|
|
1996
|
+
float(t0_val),
|
|
1997
|
+
float(t1_val),
|
|
1998
|
+
style_cur,
|
|
1999
|
+
color_cur,
|
|
2000
|
+
)
|
|
2001
|
+
)
|
|
2002
|
+
except Exception:
|
|
2003
|
+
continue
|
|
2004
|
+
|
|
2005
|
+
# Parse figsize early (string like (6,4) or [6,4]) but apply at end
|
|
2006
|
+
def _parse_figsize(val: Any):
|
|
2007
|
+
if not isinstance(val, str):
|
|
2008
|
+
return None
|
|
2009
|
+
s = val.strip()
|
|
2010
|
+
if not s:
|
|
2011
|
+
return None
|
|
2012
|
+
lit = _safe_literal(s)
|
|
2013
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 2:
|
|
2014
|
+
try:
|
|
2015
|
+
w = float(lit[0])
|
|
2016
|
+
h = float(lit[1])
|
|
2017
|
+
if w > 0 and h > 0:
|
|
2018
|
+
return (w, h)
|
|
2019
|
+
except Exception:
|
|
2020
|
+
return None
|
|
2021
|
+
# fallback simple regex (a,b)
|
|
2022
|
+
m = re.match(r"\(\s*([0-9]+(?:\.[0-9]+)?)\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)", s)
|
|
2023
|
+
if m:
|
|
2024
|
+
try:
|
|
2025
|
+
w = float(m.group(1))
|
|
2026
|
+
h = float(m.group(2))
|
|
2027
|
+
if w > 0 and h > 0:
|
|
2028
|
+
return (w, h)
|
|
2029
|
+
except Exception:
|
|
2030
|
+
return None
|
|
2031
|
+
return None
|
|
2032
|
+
|
|
2033
|
+
parsed_figsize = _parse_figsize(figsize_raw)
|
|
2034
|
+
|
|
2035
|
+
# Explicit LaTeX text rendering control. Default to True for consistent LaTeX fonts.
|
|
2036
|
+
# If the directive option is omitted, fall back to global config value 'plot_default_usetex' (defaults True).
|
|
2037
|
+
usetex_opt = _parse_bool(merged.get("usetex"), default=None)
|
|
2038
|
+
default_cfg = getattr(env.config, "plot_default_usetex", True)
|
|
2039
|
+
use_usetex = bool(usetex_opt) if usetex_opt is not None else bool(default_cfg)
|
|
2040
|
+
|
|
2041
|
+
# Hash includes all content affecting the image
|
|
2042
|
+
content_hash = _hash_key(
|
|
2043
|
+
"|".join(fn_exprs),
|
|
2044
|
+
"|".join(fn_labels_list),
|
|
2045
|
+
"|".join(["" if d is None else f"{d[0]},{d[1]}" for d in fn_domains_list]),
|
|
2046
|
+
"|".join([("|".join([str(x) for x in exs])) for exs in fn_exclusions_list]),
|
|
2047
|
+
";".join([f"{x},{y}" for x, y in point_vals]),
|
|
2048
|
+
";".join(
|
|
2049
|
+
[f"{xt[0]},{xt[1]}->{xy[0]},{xy[1]}:{t}:{arc}" for (xt, xy, t, arc) in ann_vals]
|
|
2050
|
+
),
|
|
2051
|
+
";".join(
|
|
2052
|
+
[
|
|
2053
|
+
(f"{x}" if y0 is None or y1 is None else f"{x},{y0},{y1}")
|
|
2054
|
+
+ f":{st or ''}:{col or ''}"
|
|
2055
|
+
for (x, y0, y1, st, col) in vline_vals
|
|
2056
|
+
]
|
|
2057
|
+
),
|
|
2058
|
+
";".join(
|
|
2059
|
+
[
|
|
2060
|
+
(f"{y}" if x0 is None or x1 is None else f"{y},{x0},{x1}")
|
|
2061
|
+
+ f":{st or ''}:{col or ''}"
|
|
2062
|
+
for (y, x0, x1, st, col) in hline_vals
|
|
2063
|
+
]
|
|
2064
|
+
),
|
|
2065
|
+
";".join(
|
|
2066
|
+
[
|
|
2067
|
+
f"{int(show)}:" + "|".join([f"{x},{y}" for (x, y) in pts])
|
|
2068
|
+
for (pts, show) in poly_vals
|
|
2069
|
+
]
|
|
2070
|
+
),
|
|
2071
|
+
";".join(
|
|
2072
|
+
[
|
|
2073
|
+
(color or "")
|
|
2074
|
+
+ ":"
|
|
2075
|
+
+ ("" if alpha is None else str(alpha))
|
|
2076
|
+
+ ":"
|
|
2077
|
+
+ "|".join([f"{x},{y}" for (x, y) in pts])
|
|
2078
|
+
for (pts, color, alpha) in poly_fill_vals
|
|
2079
|
+
]
|
|
2080
|
+
),
|
|
2081
|
+
";".join(
|
|
2082
|
+
[
|
|
2083
|
+
f"{p1[0]},{p1[1]}->{p2[0]},{p2[1]}:{(st or '')}:{(col or '')}"
|
|
2084
|
+
for (p1, p2, st, col) in line_segment_vals
|
|
2085
|
+
]
|
|
2086
|
+
),
|
|
2087
|
+
";".join(
|
|
2088
|
+
[
|
|
2089
|
+
f"{cx},{cy}:{r}:{sa}:{ea}:{(st or '')}:{(col or '')}"
|
|
2090
|
+
for (cx, cy, r, sa, ea, st, col) in angle_arcs
|
|
2091
|
+
]
|
|
2092
|
+
),
|
|
2093
|
+
";".join(
|
|
2094
|
+
[
|
|
2095
|
+
f"{xy[0]},{xy[1]}:{length}:{orientation}"
|
|
2096
|
+
for (xy, length, orientation) in bar_vals
|
|
2097
|
+
]
|
|
2098
|
+
),
|
|
2099
|
+
";".join([f"{x},{y}:{dx},{dy}:{col}" for (x, y, dx, dy, col) in vector_vals]),
|
|
2100
|
+
"|".join(axis_cmds),
|
|
2101
|
+
";".join([f"{a},{b}:{(st or '')}:{(col or '')}" for (a, b, st, col) in line_vals]),
|
|
2102
|
+
";".join(
|
|
2103
|
+
[
|
|
2104
|
+
f"{x},{y}:{txt}:{pos}:{int(1 if bbox else 0)}"
|
|
2105
|
+
for (x, y, txt, pos, bbox) in text_vals
|
|
2106
|
+
]
|
|
2107
|
+
),
|
|
2108
|
+
"|".join(fn_colors_list),
|
|
2109
|
+
xmin,
|
|
2110
|
+
xmax,
|
|
2111
|
+
ymin,
|
|
2112
|
+
ymax,
|
|
2113
|
+
xstep,
|
|
2114
|
+
ystep,
|
|
2115
|
+
fontsize,
|
|
2116
|
+
lw,
|
|
2117
|
+
alpha,
|
|
2118
|
+
str(merged.get("xlabel", "")),
|
|
2119
|
+
str(merged.get("ylabel", "")),
|
|
2120
|
+
int(bool(ticks_flag)),
|
|
2121
|
+
int(bool(grid_flag)),
|
|
2122
|
+
str(merged.get("xticks", "")),
|
|
2123
|
+
str(merged.get("yticks", "")),
|
|
2124
|
+
str(parsed_figsize),
|
|
2125
|
+
int(bool(use_usetex)),
|
|
2126
|
+
)
|
|
2127
|
+
base_name = explicit_name or f"plot_{content_hash}"
|
|
2128
|
+
|
|
2129
|
+
rel_dir = os.path.join("_static", "plot")
|
|
2130
|
+
abs_dir = os.path.join(app.srcdir, rel_dir)
|
|
2131
|
+
os.makedirs(abs_dir, exist_ok=True)
|
|
2132
|
+
svg_name = f"{base_name}.svg"
|
|
2133
|
+
abs_svg = os.path.join(abs_dir, svg_name)
|
|
2134
|
+
|
|
2135
|
+
regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
|
|
2136
|
+
if regenerate:
|
|
2137
|
+
import matplotlib
|
|
2138
|
+
|
|
2139
|
+
matplotlib.use("Agg")
|
|
2140
|
+
try:
|
|
2141
|
+
# Ensure consistent text rendering from drawing through save.
|
|
2142
|
+
_old_usetex = matplotlib.rcParams.get("text.usetex")
|
|
2143
|
+
_old_mathtext = matplotlib.rcParams.get("mathtext.fontset")
|
|
2144
|
+
try:
|
|
2145
|
+
matplotlib.rcParams["text.usetex"] = use_usetex
|
|
2146
|
+
# Prefer Computer Modern math text when not using external LaTeX
|
|
2147
|
+
if not use_usetex:
|
|
2148
|
+
matplotlib.rcParams["mathtext.fontset"] = "cm"
|
|
2149
|
+
except Exception:
|
|
2150
|
+
pass
|
|
2151
|
+
# Determine axis flags early
|
|
2152
|
+
axis_off = any(str(c).lower() == "off" for c in axis_cmds)
|
|
2153
|
+
axis_equal = any(str(c).lower() == "equal" for c in axis_cmds)
|
|
2154
|
+
|
|
2155
|
+
if axis_off:
|
|
2156
|
+
# Create a plain figure/axes (no plotmath.plot) so nothing (ticks/grid)
|
|
2157
|
+
# is drawn before we hide the axes.
|
|
2158
|
+
import matplotlib.pyplot as _plt
|
|
2159
|
+
|
|
2160
|
+
fig, ax = _plt.subplots()
|
|
2161
|
+
# Provide a reasonable default size similar to plotmath defaults
|
|
2162
|
+
fig.set_size_inches(6.0, 6.0)
|
|
2163
|
+
ax.set_xlim(xmin, xmax)
|
|
2164
|
+
ax.set_ylim(ymin, ymax)
|
|
2165
|
+
# Hide coordinate system
|
|
2166
|
+
try:
|
|
2167
|
+
ax.axis("off")
|
|
2168
|
+
except Exception:
|
|
2169
|
+
pass
|
|
2170
|
+
# Apply equal aspect if requested
|
|
2171
|
+
if axis_equal:
|
|
2172
|
+
try:
|
|
2173
|
+
ax.axis("equal")
|
|
2174
|
+
except Exception:
|
|
2175
|
+
pass
|
|
2176
|
+
else:
|
|
2177
|
+
# Standard path: delegate axis setup (ticks, grid, labels) to plotmath
|
|
2178
|
+
fig, ax = plotmath.plot(
|
|
2179
|
+
functions=[],
|
|
2180
|
+
fn_labels=False,
|
|
2181
|
+
xmin=xmin,
|
|
2182
|
+
xmax=xmax,
|
|
2183
|
+
ymin=ymin,
|
|
2184
|
+
ymax=ymax,
|
|
2185
|
+
xstep=xstep,
|
|
2186
|
+
ystep=ystep,
|
|
2187
|
+
ticks=ticks_flag,
|
|
2188
|
+
grid=grid_flag,
|
|
2189
|
+
lw=lw,
|
|
2190
|
+
alpha=alpha,
|
|
2191
|
+
fontsize=fontsize,
|
|
2192
|
+
)
|
|
2193
|
+
# If equal requested (without off), apply after plot creation
|
|
2194
|
+
if axis_equal:
|
|
2195
|
+
try:
|
|
2196
|
+
ax.axis("equal")
|
|
2197
|
+
except Exception:
|
|
2198
|
+
pass
|
|
2199
|
+
|
|
2200
|
+
# Plot requested functions directly on ax, with optional labels, per-function domains, and exclusions
|
|
2201
|
+
if functions:
|
|
2202
|
+
import numpy as np
|
|
2203
|
+
|
|
2204
|
+
any_label = False
|
|
2205
|
+
for f, lbl, dom, exs, col_fun in zip(
|
|
2206
|
+
functions,
|
|
2207
|
+
fn_labels_list,
|
|
2208
|
+
fn_domains_list,
|
|
2209
|
+
fn_exclusions_list,
|
|
2210
|
+
fn_colors_list,
|
|
2211
|
+
):
|
|
2212
|
+
x0, x1 = dom if dom is not None else (xmin, xmax)
|
|
2213
|
+
N = int(2**12)
|
|
2214
|
+
x = np.linspace(x0, x1, N)
|
|
2215
|
+
y = f(x)
|
|
2216
|
+
# Ensure float array and blank out non-finite values
|
|
2217
|
+
y = np.asarray(y, dtype=float)
|
|
2218
|
+
y[~np.isfinite(y)] = np.nan
|
|
2219
|
+
# More robust exclusion handling: blank a window around each excluded x
|
|
2220
|
+
exs_in = [e for e in exs if x0 < e < x1]
|
|
2221
|
+
if exs_in and N > 1:
|
|
2222
|
+
dx = (x1 - x0) / (N - 1)
|
|
2223
|
+
# Window width larger than step to ensure a gap; include tiny absolute floor
|
|
2224
|
+
w = max(4 * dx, 1e-6 * (1.0 + max(abs(e) for e in exs_in)))
|
|
2225
|
+
for e in exs_in:
|
|
2226
|
+
try:
|
|
2227
|
+
mask = np.abs(x - e) <= w
|
|
2228
|
+
if mask.any():
|
|
2229
|
+
y[mask] = np.nan
|
|
2230
|
+
# Also blank the nearest index and a couple of neighbors to guarantee a break
|
|
2231
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
2232
|
+
for k in (j - 2, j - 1, j, j + 1, j + 2):
|
|
2233
|
+
if 0 <= k < y.size:
|
|
2234
|
+
y[k] = np.nan
|
|
2235
|
+
except Exception:
|
|
2236
|
+
# Last resort: nearest index only
|
|
2237
|
+
try:
|
|
2238
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
2239
|
+
if 0 <= j < y.size:
|
|
2240
|
+
y[j] = np.nan
|
|
2241
|
+
except Exception:
|
|
2242
|
+
pass
|
|
2243
|
+
# Additionally, break lines across steep jumps or extreme values
|
|
2244
|
+
# Determine a reasonable y-span for thresholds
|
|
2245
|
+
y_span = (
|
|
2246
|
+
abs(ymax - ymin) if (ymax is not None and ymin is not None) else np.nan
|
|
2247
|
+
)
|
|
2248
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
2249
|
+
finite_y = y[np.isfinite(y)]
|
|
2250
|
+
if finite_y.size > 0:
|
|
2251
|
+
y_span = float(np.nanmax(finite_y) - np.nanmin(finite_y))
|
|
2252
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
2253
|
+
y_span = 1.0
|
|
2254
|
+
# Break where adjacent points jump too much relative to span
|
|
2255
|
+
jump_factor = 0.5 # half the axis span signals discontinuity
|
|
2256
|
+
finite_pair = np.isfinite(y[:-1]) & np.isfinite(y[1:])
|
|
2257
|
+
big_jump = finite_pair & (np.abs(y[1:] - y[:-1]) > (jump_factor * y_span))
|
|
2258
|
+
if big_jump.any():
|
|
2259
|
+
idx_break = np.where(big_jump)[0]
|
|
2260
|
+
for i_b in idx_break:
|
|
2261
|
+
if 0 <= i_b + 1 < y.size:
|
|
2262
|
+
y[i_b + 1] = np.nan
|
|
2263
|
+
# Mask values far outside typical range to avoid vertical spikes drawing across
|
|
2264
|
+
mag_factor = 50.0
|
|
2265
|
+
too_big = np.isfinite(y) & (np.abs(y) > (mag_factor * y_span))
|
|
2266
|
+
if too_big.any():
|
|
2267
|
+
y[too_big] = np.nan
|
|
2268
|
+
# Resolve per-function color if provided
|
|
2269
|
+
_col_use = None
|
|
2270
|
+
if isinstance(col_fun, str) and col_fun.strip():
|
|
2271
|
+
try:
|
|
2272
|
+
_col_map = plotmath.COLORS.get(col_fun)
|
|
2273
|
+
except Exception:
|
|
2274
|
+
_col_map = None
|
|
2275
|
+
_col_use = _col_map if _col_map else col_fun
|
|
2276
|
+
|
|
2277
|
+
if lbl:
|
|
2278
|
+
any_label = True
|
|
2279
|
+
ax.plot(
|
|
2280
|
+
x,
|
|
2281
|
+
y,
|
|
2282
|
+
lw=lw,
|
|
2283
|
+
alpha=alpha,
|
|
2284
|
+
label=f"${lbl}$",
|
|
2285
|
+
**({"color": _col_use} if _col_use else {}),
|
|
2286
|
+
)
|
|
2287
|
+
else:
|
|
2288
|
+
ax.plot(
|
|
2289
|
+
x,
|
|
2290
|
+
y,
|
|
2291
|
+
lw=lw,
|
|
2292
|
+
alpha=alpha,
|
|
2293
|
+
**({"color": _col_use} if _col_use else {}),
|
|
2294
|
+
)
|
|
2295
|
+
if any_label:
|
|
2296
|
+
ax.legend(fontsize=int(fontsize))
|
|
2297
|
+
|
|
2298
|
+
# Annotations
|
|
2299
|
+
for xytext, xy, text, arc in ann_vals:
|
|
2300
|
+
plotmath.annotate(xy=xy, xytext=xytext, s=text, arc=arc, fontsize=int(fontsize))
|
|
2301
|
+
|
|
2302
|
+
# Lines (y = a*x + b); draw before points so markers remain visible
|
|
2303
|
+
if line_vals:
|
|
2304
|
+
import numpy as _np_l
|
|
2305
|
+
|
|
2306
|
+
style_map_line = {
|
|
2307
|
+
"solid": "-",
|
|
2308
|
+
"dotted": ":",
|
|
2309
|
+
"dashed": "--",
|
|
2310
|
+
"dashdot": "-.",
|
|
2311
|
+
}
|
|
2312
|
+
default_color_line = plotmath.COLORS.get("red")
|
|
2313
|
+
try:
|
|
2314
|
+
from matplotlib import colors as _mcolors
|
|
2315
|
+
except Exception:
|
|
2316
|
+
_mcolors = None
|
|
2317
|
+
x_line = _np_l.array([xmin, xmax], dtype=float)
|
|
2318
|
+
for a_l, b_l, st_l, col_l in line_vals:
|
|
2319
|
+
y_line = a_l * x_line + b_l
|
|
2320
|
+
ls = style_map_line.get((st_l or "dashed").lower(), "--")
|
|
2321
|
+
# Resolve color via plotmath.COLORS if provided; fallback to original token then default
|
|
2322
|
+
if col_l:
|
|
2323
|
+
_mapped = plotmath.COLORS.get(col_l)
|
|
2324
|
+
else:
|
|
2325
|
+
_mapped = None
|
|
2326
|
+
col_use = (_mapped if _mapped else col_l) or default_color_line
|
|
2327
|
+
if _mcolors is not None:
|
|
2328
|
+
try:
|
|
2329
|
+
_ = _mcolors.to_rgba(col_use)
|
|
2330
|
+
except Exception:
|
|
2331
|
+
col_use = default_color_line
|
|
2332
|
+
try:
|
|
2333
|
+
ax.plot(
|
|
2334
|
+
x_line,
|
|
2335
|
+
y_line,
|
|
2336
|
+
linestyle=ls,
|
|
2337
|
+
color=col_use,
|
|
2338
|
+
lw=lw,
|
|
2339
|
+
alpha=alpha,
|
|
2340
|
+
)
|
|
2341
|
+
except Exception:
|
|
2342
|
+
ax.plot(
|
|
2343
|
+
x_line,
|
|
2344
|
+
y_line,
|
|
2345
|
+
linestyle=ls,
|
|
2346
|
+
color=default_color_line,
|
|
2347
|
+
lw=lw,
|
|
2348
|
+
alpha=alpha,
|
|
2349
|
+
)
|
|
2350
|
+
|
|
2351
|
+
# Bars
|
|
2352
|
+
for xy, length, orientation in bar_vals:
|
|
2353
|
+
try:
|
|
2354
|
+
# Prefer plotmath.make_bar if available
|
|
2355
|
+
if hasattr(plotmath, "make_bar"):
|
|
2356
|
+
plotmath.make_bar(xy, length, orientation)
|
|
2357
|
+
else:
|
|
2358
|
+
# Fallback: use annotate directly on this axes
|
|
2359
|
+
x, y = xy
|
|
2360
|
+
if orientation == "horizontal":
|
|
2361
|
+
ax.annotate(
|
|
2362
|
+
"",
|
|
2363
|
+
xy=xy,
|
|
2364
|
+
xycoords="data",
|
|
2365
|
+
xytext=(x + length, y),
|
|
2366
|
+
textcoords="data",
|
|
2367
|
+
arrowprops=dict(
|
|
2368
|
+
arrowstyle="|-|,widthA=0.5,widthB=0.5",
|
|
2369
|
+
color="black",
|
|
2370
|
+
),
|
|
2371
|
+
)
|
|
2372
|
+
else:
|
|
2373
|
+
ax.annotate(
|
|
2374
|
+
"",
|
|
2375
|
+
xy=xy,
|
|
2376
|
+
xycoords="data",
|
|
2377
|
+
xytext=(x, y + length),
|
|
2378
|
+
textcoords="data",
|
|
2379
|
+
arrowprops=dict(
|
|
2380
|
+
arrowstyle="|-|,widthA=0.5,widthB=0.5",
|
|
2381
|
+
color="black",
|
|
2382
|
+
),
|
|
2383
|
+
)
|
|
2384
|
+
except Exception:
|
|
2385
|
+
pass
|
|
2386
|
+
|
|
2387
|
+
# Angle arcs
|
|
2388
|
+
if "angle_arcs" in locals() and angle_arcs:
|
|
2389
|
+
try:
|
|
2390
|
+
import numpy as _np_ang
|
|
2391
|
+
except Exception:
|
|
2392
|
+
_np_ang = None
|
|
2393
|
+
if _np_ang is not None:
|
|
2394
|
+
style_map_arc = {
|
|
2395
|
+
"solid": "-",
|
|
2396
|
+
"dotted": ":",
|
|
2397
|
+
"dashed": "--",
|
|
2398
|
+
"dashdot": "-.",
|
|
2399
|
+
}
|
|
2400
|
+
default_arc_color = plotmath.COLORS.get("black") or "black"
|
|
2401
|
+
for cx, cy, r, sa_deg, ea_deg, st_a, col_a in angle_arcs:
|
|
2402
|
+
try:
|
|
2403
|
+
sa = _np_ang.deg2rad(sa_deg)
|
|
2404
|
+
ea = _np_ang.deg2rad(ea_deg)
|
|
2405
|
+
theta = _np_ang.linspace(sa, ea, 1024)
|
|
2406
|
+
xs = cx + r * _np_ang.cos(theta)
|
|
2407
|
+
ys = cy + r * _np_ang.sin(theta)
|
|
2408
|
+
ls_use = style_map_arc.get((st_a or "solid").lower(), "-")
|
|
2409
|
+
# Resolve color via plotmath palette
|
|
2410
|
+
if col_a:
|
|
2411
|
+
_mapped = plotmath.COLORS.get(col_a)
|
|
2412
|
+
else:
|
|
2413
|
+
_mapped = None
|
|
2414
|
+
col_use = (_mapped if _mapped else col_a) or default_arc_color
|
|
2415
|
+
ax.plot(xs, ys, lw=1, color=col_use, linestyle=ls_use)
|
|
2416
|
+
except Exception:
|
|
2417
|
+
pass
|
|
2418
|
+
|
|
2419
|
+
# text with optional positioning and optional bbox
|
|
2420
|
+
|
|
2421
|
+
xmin, xmax = ax.get_xlim()
|
|
2422
|
+
ymin, ymax = ax.get_ylim()
|
|
2423
|
+
ax_dx = xmax - xmin
|
|
2424
|
+
ax_dy = ymax - ymin
|
|
2425
|
+
|
|
2426
|
+
# Determine axes pixel size for consistent visual offsets
|
|
2427
|
+
try:
|
|
2428
|
+
fig.canvas.draw() # ensure layout is realized
|
|
2429
|
+
_bbox_px = ax.get_window_extent()
|
|
2430
|
+
_ax_w_px, _ax_h_px = _bbox_px.width, _bbox_px.height
|
|
2431
|
+
if _ax_w_px <= 0 or _ax_h_px <= 0:
|
|
2432
|
+
_ax_w_px = _ax_h_px = None
|
|
2433
|
+
except Exception:
|
|
2434
|
+
_ax_w_px = _ax_h_px = None
|
|
2435
|
+
|
|
2436
|
+
for x0, y0, text, pos, use_bbox in text_vals:
|
|
2437
|
+
va, ha = _parse_text_positioning(pos)
|
|
2438
|
+
# Factors as fractions of axes size; keep long* ~3.3x larger
|
|
2439
|
+
_fx_short = 0.015
|
|
2440
|
+
_fy_short = 0.015
|
|
2441
|
+
_fx_long = 0.03
|
|
2442
|
+
_fy_long = 0.03
|
|
2443
|
+
|
|
2444
|
+
# Resolve long* into base alignment while keeping larger factors
|
|
2445
|
+
_use_fx = _fx_short
|
|
2446
|
+
_use_fy = _fy_short
|
|
2447
|
+
if va == "longbottom":
|
|
2448
|
+
va = "bottom"
|
|
2449
|
+
_use_fy = _fy_long
|
|
2450
|
+
elif va == "longtop":
|
|
2451
|
+
va = "top"
|
|
2452
|
+
_use_fy = _fy_long
|
|
2453
|
+
if ha == "longright":
|
|
2454
|
+
ha = "right"
|
|
2455
|
+
_use_fx = _fx_long
|
|
2456
|
+
elif ha == "longleft":
|
|
2457
|
+
ha = "left"
|
|
2458
|
+
_use_fx = _fx_long
|
|
2459
|
+
|
|
2460
|
+
if _ax_w_px and _ax_h_px:
|
|
2461
|
+
# Pixel-based offsets converted back to data units
|
|
2462
|
+
dx_px = 0.0
|
|
2463
|
+
dy_px = 0.0
|
|
2464
|
+
if ha == "right":
|
|
2465
|
+
dx_px = -_ax_w_px * _use_fx
|
|
2466
|
+
elif ha == "left":
|
|
2467
|
+
dx_px = _ax_w_px * _use_fx
|
|
2468
|
+
if va == "bottom":
|
|
2469
|
+
dy_px = _ax_h_px * _use_fy
|
|
2470
|
+
elif va == "top":
|
|
2471
|
+
dy_px = -_ax_h_px * _use_fy
|
|
2472
|
+
x_disp, y_disp = ax.transData.transform((x0, y0))
|
|
2473
|
+
x1, y1 = ax.transData.inverted().transform((x_disp + dx_px, y_disp + dy_px))
|
|
2474
|
+
dx = x1 - x0
|
|
2475
|
+
dy = y1 - y0
|
|
2476
|
+
else:
|
|
2477
|
+
# Fallback to fractions of data span
|
|
2478
|
+
if va == "bottom":
|
|
2479
|
+
dy = _fy_short * ax_dy if _use_fy == _fy_short else _fy_long * ax_dy
|
|
2480
|
+
elif va == "top":
|
|
2481
|
+
dy = -(_fy_short * ax_dy if _use_fy == _fy_short else _fy_long * ax_dy)
|
|
2482
|
+
else:
|
|
2483
|
+
dy = 0.0
|
|
2484
|
+
if ha == "right":
|
|
2485
|
+
dx = -(_fx_short * ax_dx if _use_fx == _fx_short else _fx_long * ax_dx)
|
|
2486
|
+
elif ha == "left":
|
|
2487
|
+
dx = _fx_short * ax_dx if _use_fx == _fx_short else _fx_long * ax_dx
|
|
2488
|
+
else:
|
|
2489
|
+
dx = 0.0
|
|
2490
|
+
|
|
2491
|
+
bbox_kwargs = (
|
|
2492
|
+
dict(
|
|
2493
|
+
boxstyle="round,pad=0.4",
|
|
2494
|
+
fc="white",
|
|
2495
|
+
ec="black",
|
|
2496
|
+
lw=1.5,
|
|
2497
|
+
alpha=0.7,
|
|
2498
|
+
)
|
|
2499
|
+
if use_bbox
|
|
2500
|
+
else None
|
|
2501
|
+
)
|
|
2502
|
+
|
|
2503
|
+
if bbox_kwargs:
|
|
2504
|
+
ax.text(
|
|
2505
|
+
x0 + 1.5 * dx,
|
|
2506
|
+
y0 + 1.5 * dy,
|
|
2507
|
+
text,
|
|
2508
|
+
fontsize=int(fontsize),
|
|
2509
|
+
ha=ha,
|
|
2510
|
+
va=va,
|
|
2511
|
+
bbox=bbox_kwargs,
|
|
2512
|
+
)
|
|
2513
|
+
else:
|
|
2514
|
+
ax.text(x0 + dx, y0 + dy, text, fontsize=int(fontsize), ha=ha, va=va)
|
|
2515
|
+
|
|
2516
|
+
# line segments (draw before vlines/hlines so guides overlay if needed)
|
|
2517
|
+
if "line_segment_vals" in locals() and line_segment_vals:
|
|
2518
|
+
style_map_seg = {
|
|
2519
|
+
"solid": "-",
|
|
2520
|
+
"dotted": ":",
|
|
2521
|
+
"dashed": "--",
|
|
2522
|
+
"dashdot": "-.",
|
|
2523
|
+
}
|
|
2524
|
+
default_seg_color = plotmath.COLORS.get("red")
|
|
2525
|
+
try:
|
|
2526
|
+
from matplotlib import colors as _mcolors_seg
|
|
2527
|
+
except Exception:
|
|
2528
|
+
_mcolors_seg = None
|
|
2529
|
+
for p1, p2, st_seg, col_seg in line_segment_vals:
|
|
2530
|
+
(x1s, y1s), (x2s, y2s) = p1, p2
|
|
2531
|
+
ls_use = style_map_seg.get((st_seg or "solid").lower(), "-")
|
|
2532
|
+
if col_seg:
|
|
2533
|
+
_mapped_seg = plotmath.COLORS.get(col_seg)
|
|
2534
|
+
else:
|
|
2535
|
+
_mapped_seg = None
|
|
2536
|
+
col_use = (_mapped_seg if _mapped_seg else col_seg) or default_seg_color
|
|
2537
|
+
if _mcolors_seg is not None:
|
|
2538
|
+
try:
|
|
2539
|
+
_ = _mcolors_seg.to_rgba(col_use)
|
|
2540
|
+
except Exception:
|
|
2541
|
+
col_use = default_seg_color
|
|
2542
|
+
try:
|
|
2543
|
+
ax.plot(
|
|
2544
|
+
[x1s, x2s],
|
|
2545
|
+
[y1s, y2s],
|
|
2546
|
+
linestyle=ls_use,
|
|
2547
|
+
color=col_use,
|
|
2548
|
+
lw=lw,
|
|
2549
|
+
)
|
|
2550
|
+
except Exception:
|
|
2551
|
+
pass
|
|
2552
|
+
# Circles
|
|
2553
|
+
if "circle_vals" in locals() and circle_vals:
|
|
2554
|
+
try:
|
|
2555
|
+
from matplotlib import patches as _mpatches_c
|
|
2556
|
+
except Exception:
|
|
2557
|
+
_mpatches_c = None
|
|
2558
|
+
if _mpatches_c is not None:
|
|
2559
|
+
style_map_circle = {
|
|
2560
|
+
"solid": "-",
|
|
2561
|
+
"dotted": ":",
|
|
2562
|
+
"dashed": "--",
|
|
2563
|
+
"dashdot": "-.",
|
|
2564
|
+
}
|
|
2565
|
+
default_circle_color = plotmath.COLORS.get("black") or "black"
|
|
2566
|
+
for cx, cy, r_c, st_c, col_c in circle_vals:
|
|
2567
|
+
try:
|
|
2568
|
+
# Resolve color
|
|
2569
|
+
if col_c:
|
|
2570
|
+
mapped = plotmath.COLORS.get(col_c)
|
|
2571
|
+
else:
|
|
2572
|
+
mapped = None
|
|
2573
|
+
col_use = (mapped if mapped else col_c) or default_circle_color
|
|
2574
|
+
# Resolve linestyle -> we pass as linestyle on patch edge
|
|
2575
|
+
ls_use = style_map_circle.get((st_c or "solid").lower(), "-")
|
|
2576
|
+
circ = _mpatches_c.Circle(
|
|
2577
|
+
(cx, cy),
|
|
2578
|
+
r_c,
|
|
2579
|
+
fill=False,
|
|
2580
|
+
edgecolor=col_use,
|
|
2581
|
+
facecolor="none",
|
|
2582
|
+
linestyle=ls_use,
|
|
2583
|
+
lw=lw,
|
|
2584
|
+
)
|
|
2585
|
+
ax.add_patch(circ)
|
|
2586
|
+
except Exception:
|
|
2587
|
+
pass
|
|
2588
|
+
# Ellipses
|
|
2589
|
+
if "ellipse_vals" in locals() and ellipse_vals:
|
|
2590
|
+
try:
|
|
2591
|
+
import numpy as _np_el
|
|
2592
|
+
except Exception:
|
|
2593
|
+
_np_el = None
|
|
2594
|
+
if _np_el is not None:
|
|
2595
|
+
style_map_ellipse = {
|
|
2596
|
+
"solid": "-",
|
|
2597
|
+
"dotted": ":",
|
|
2598
|
+
"dashed": "--",
|
|
2599
|
+
"dashdot": "-.",
|
|
2600
|
+
}
|
|
2601
|
+
default_ellipse_color = plotmath.COLORS.get("black") or "black"
|
|
2602
|
+
for x0e, y0e, a_e, b_e, st_e, col_e in ellipse_vals:
|
|
2603
|
+
try:
|
|
2604
|
+
t = _np_el.linspace(0, 2 * _np_el.pi, 1024)
|
|
2605
|
+
xs = x0e + a_e * _np_el.cos(t)
|
|
2606
|
+
ys = y0e + b_e * _np_el.sin(t)
|
|
2607
|
+
if col_e:
|
|
2608
|
+
mapped = plotmath.COLORS.get(col_e)
|
|
2609
|
+
else:
|
|
2610
|
+
mapped = None
|
|
2611
|
+
col_use = (mapped if mapped else col_e) or default_ellipse_color
|
|
2612
|
+
ls_use = style_map_ellipse.get((st_e or "solid").lower(), "-")
|
|
2613
|
+
ax.plot(xs, ys, color=col_use, linestyle=ls_use, lw=lw)
|
|
2614
|
+
except Exception:
|
|
2615
|
+
pass
|
|
2616
|
+
|
|
2617
|
+
# Curves (parametric x(t), y(t))
|
|
2618
|
+
if "curve_specs" in locals() and curve_specs:
|
|
2619
|
+
try:
|
|
2620
|
+
import sympy as _sp_curve
|
|
2621
|
+
import numpy as _np_curve
|
|
2622
|
+
except Exception:
|
|
2623
|
+
_sp_curve = None
|
|
2624
|
+
_np_curve = None
|
|
2625
|
+
if _sp_curve is not None and _np_curve is not None:
|
|
2626
|
+
style_map_curve = {
|
|
2627
|
+
"solid": "-",
|
|
2628
|
+
"dotted": ":",
|
|
2629
|
+
"dashed": "--",
|
|
2630
|
+
"dashdot": "-.",
|
|
2631
|
+
}
|
|
2632
|
+
default_curve_color = plotmath.COLORS.get("black") or "black"
|
|
2633
|
+
for x_expr_s, y_expr_s, t0_c, t1_c, st_c, col_c in curve_specs:
|
|
2634
|
+
try:
|
|
2635
|
+
t_sym = _sp_curve.symbols("t")
|
|
2636
|
+
# Sympify with local symbol t; rely on SymPy's safe parsing (no arbitrary exec)
|
|
2637
|
+
x_sym = _sp_curve.sympify(x_expr_s, locals={"t": t_sym})
|
|
2638
|
+
y_sym = _sp_curve.sympify(y_expr_s, locals={"t": t_sym})
|
|
2639
|
+
fx = _sp_curve.lambdify(t_sym, x_sym, "numpy")
|
|
2640
|
+
fy = _sp_curve.lambdify(t_sym, y_sym, "numpy")
|
|
2641
|
+
t_arr = _np_curve.linspace(t0_c, t1_c, 1024)
|
|
2642
|
+
xs = fx(t_arr)
|
|
2643
|
+
ys = fy(t_arr)
|
|
2644
|
+
# Basic sanity checks
|
|
2645
|
+
try:
|
|
2646
|
+
_ = len(xs)
|
|
2647
|
+
_ = len(ys)
|
|
2648
|
+
except Exception:
|
|
2649
|
+
continue
|
|
2650
|
+
mapped = plotmath.COLORS.get(col_c) if col_c else None
|
|
2651
|
+
col_use = (mapped if mapped else col_c) or default_curve_color
|
|
2652
|
+
ls_use = style_map_curve.get((st_c or "solid").lower(), "-")
|
|
2653
|
+
ax.plot(xs, ys, color=col_use, linestyle=ls_use, lw=lw)
|
|
2654
|
+
except Exception:
|
|
2655
|
+
continue
|
|
2656
|
+
|
|
2657
|
+
# vlines
|
|
2658
|
+
style_map = {
|
|
2659
|
+
"solid": "-",
|
|
2660
|
+
"dotted": ":",
|
|
2661
|
+
"dashed": "--",
|
|
2662
|
+
"dashdot": "-.",
|
|
2663
|
+
}
|
|
2664
|
+
default_color = plotmath.COLORS.get("red")
|
|
2665
|
+
for x_v, y0, y1, st, col in vline_vals:
|
|
2666
|
+
y_min = ymin if y0 is None else y0
|
|
2667
|
+
y_max = ymax if y1 is None else y1
|
|
2668
|
+
ls_val = style_map.get((st or "dashed").lower(), ":")
|
|
2669
|
+
# Resolve user color through plotmath.COLORS, then fallback to original, then default
|
|
2670
|
+
_mapped = plotmath.COLORS.get(col) if col else None
|
|
2671
|
+
color_to_try = (_mapped if _mapped else col) or default_color
|
|
2672
|
+
try:
|
|
2673
|
+
ax.vlines(
|
|
2674
|
+
x=x_v,
|
|
2675
|
+
ymin=y_min,
|
|
2676
|
+
ymax=y_max,
|
|
2677
|
+
colors=color_to_try,
|
|
2678
|
+
lw=lw,
|
|
2679
|
+
alpha=1,
|
|
2680
|
+
ls=ls_val,
|
|
2681
|
+
)
|
|
2682
|
+
except Exception:
|
|
2683
|
+
ax.vlines(
|
|
2684
|
+
x=x_v,
|
|
2685
|
+
ymin=y_min,
|
|
2686
|
+
ymax=y_max,
|
|
2687
|
+
colors=default_color,
|
|
2688
|
+
lw=lw,
|
|
2689
|
+
alpha=1,
|
|
2690
|
+
ls=ls_val,
|
|
2691
|
+
)
|
|
2692
|
+
|
|
2693
|
+
# hlines
|
|
2694
|
+
for y_h, x0, x1, st_h, col_h in hline_vals:
|
|
2695
|
+
x_min = xmin if x0 is None else x0
|
|
2696
|
+
x_max = xmax if x1 is None else x1
|
|
2697
|
+
ls_val_h = style_map.get((st_h or "dashed").lower(), ":")
|
|
2698
|
+
# Resolve user color through plotmath.COLORS, then fallback to original, then default
|
|
2699
|
+
_mapped_h = plotmath.COLORS.get(col_h) if col_h else None
|
|
2700
|
+
color_to_try_h = (_mapped_h if _mapped_h else col_h) or default_color
|
|
2701
|
+
try:
|
|
2702
|
+
ax.hlines(
|
|
2703
|
+
y=y_h,
|
|
2704
|
+
xmin=x_min,
|
|
2705
|
+
xmax=x_max,
|
|
2706
|
+
colors=color_to_try_h,
|
|
2707
|
+
lw=lw,
|
|
2708
|
+
alpha=1,
|
|
2709
|
+
ls=ls_val_h,
|
|
2710
|
+
)
|
|
2711
|
+
except Exception:
|
|
2712
|
+
ax.hlines(
|
|
2713
|
+
y=y_h,
|
|
2714
|
+
xmin=x_min,
|
|
2715
|
+
xmax=x_max,
|
|
2716
|
+
colors=default_color,
|
|
2717
|
+
lw=lw,
|
|
2718
|
+
alpha=1,
|
|
2719
|
+
ls=ls_val_h,
|
|
2720
|
+
)
|
|
2721
|
+
|
|
2722
|
+
# polygons
|
|
2723
|
+
for pts, show in poly_vals:
|
|
2724
|
+
kwargs = {"show_vertices": True} if show else {}
|
|
2725
|
+
try:
|
|
2726
|
+
plotmath.polygon(*pts, **kwargs)
|
|
2727
|
+
except Exception:
|
|
2728
|
+
# ignore to avoid breaking the build on a single bad polygon
|
|
2729
|
+
pass
|
|
2730
|
+
|
|
2731
|
+
# filled polygons
|
|
2732
|
+
default_fill_color = plotmath.COLORS.get("blue")
|
|
2733
|
+
for pts, color_fp, alpha_fp in poly_fill_vals:
|
|
2734
|
+
# Resolve user color through plotmath.COLORS, then fallback to original, then default
|
|
2735
|
+
if color_fp:
|
|
2736
|
+
_mapped_fp = plotmath.COLORS.get(color_fp)
|
|
2737
|
+
else:
|
|
2738
|
+
_mapped_fp = None
|
|
2739
|
+
c = (_mapped_fp if _mapped_fp else color_fp) or default_fill_color
|
|
2740
|
+
a = 0.1 if alpha_fp is None else alpha_fp
|
|
2741
|
+
try:
|
|
2742
|
+
plotmath.polygon(*pts, edges=False, color=c, alpha=a)
|
|
2743
|
+
except Exception:
|
|
2744
|
+
try:
|
|
2745
|
+
plotmath.polygon(*pts, edges=False, facecolor=c, alpha=a)
|
|
2746
|
+
except Exception:
|
|
2747
|
+
plotmath.polygon(*pts, edges=False, alpha=a)
|
|
2748
|
+
|
|
2749
|
+
# Vectors (quiver) drawn before points so markers overlay arrow heads
|
|
2750
|
+
if vector_vals:
|
|
2751
|
+
default_vector_color = plotmath.COLORS.get("black") or "black"
|
|
2752
|
+
try:
|
|
2753
|
+
for x_v, y_v, dx_v, dy_v, col_v in vector_vals:
|
|
2754
|
+
# Resolve color through palette first
|
|
2755
|
+
if col_v:
|
|
2756
|
+
_mapped_vec = plotmath.COLORS.get(col_v)
|
|
2757
|
+
else:
|
|
2758
|
+
_mapped_vec = None
|
|
2759
|
+
color_use = (
|
|
2760
|
+
_mapped_vec if _mapped_vec else col_v
|
|
2761
|
+
) or default_vector_color
|
|
2762
|
+
ax.quiver(
|
|
2763
|
+
x_v,
|
|
2764
|
+
y_v,
|
|
2765
|
+
dx_v,
|
|
2766
|
+
dy_v,
|
|
2767
|
+
angles="xy",
|
|
2768
|
+
scale_units="xy",
|
|
2769
|
+
scale=1,
|
|
2770
|
+
width=0.0065,
|
|
2771
|
+
headwidth=4,
|
|
2772
|
+
headlength=4.5,
|
|
2773
|
+
color=color_use,
|
|
2774
|
+
)
|
|
2775
|
+
except Exception:
|
|
2776
|
+
pass
|
|
2777
|
+
|
|
2778
|
+
# Plot points
|
|
2779
|
+
for x0, y0 in point_vals:
|
|
2780
|
+
ax.plot(x0, y0, "o", markersize=10, alpha=0.8, color="black")
|
|
2781
|
+
|
|
2782
|
+
# axis commands (run sequentially) — retain for legacy commands; we
|
|
2783
|
+
# skip reapplying 'off'/'equal' earlier logic is already applied but
|
|
2784
|
+
# they are harmless if repeated.
|
|
2785
|
+
for cmd in axis_cmds:
|
|
2786
|
+
try:
|
|
2787
|
+
ax.axis(cmd)
|
|
2788
|
+
except Exception:
|
|
2789
|
+
pass
|
|
2790
|
+
|
|
2791
|
+
# Axis labels: allow optional labelpad via "label, pad"
|
|
2792
|
+
def _split_label_and_pad(val: Any) -> tuple[str | None, float | None]:
|
|
2793
|
+
if not isinstance(val, str):
|
|
2794
|
+
return None, None
|
|
2795
|
+
s = val.strip()
|
|
2796
|
+
if not s:
|
|
2797
|
+
return None, None
|
|
2798
|
+
# Try literal form [label, pad] or (label, pad)
|
|
2799
|
+
lit = _safe_literal(s)
|
|
2800
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 1:
|
|
2801
|
+
label = str(lit[0]).strip()
|
|
2802
|
+
pad: float | None = None
|
|
2803
|
+
if len(lit) >= 2:
|
|
2804
|
+
try:
|
|
2805
|
+
pad = float(lit[1])
|
|
2806
|
+
except Exception:
|
|
2807
|
+
pad = None
|
|
2808
|
+
return (label if label else None), pad
|
|
2809
|
+
# CSV fallback: split on last comma so labels with commas still work when quoted
|
|
2810
|
+
parts = [p.strip() for p in s.split(",")]
|
|
2811
|
+
if len(parts) >= 2:
|
|
2812
|
+
try:
|
|
2813
|
+
pad = float(parts[-1])
|
|
2814
|
+
label = ",".join(parts[:-1]).strip()
|
|
2815
|
+
return (label if label else None), pad
|
|
2816
|
+
except Exception:
|
|
2817
|
+
pass
|
|
2818
|
+
return (s if s else None), None
|
|
2819
|
+
|
|
2820
|
+
xl_raw = merged.get("xlabel")
|
|
2821
|
+
yl_raw = merged.get("ylabel")
|
|
2822
|
+
xl_text, xl_pad = _split_label_and_pad(xl_raw)
|
|
2823
|
+
yl_text, yl_pad = _split_label_and_pad(yl_raw)
|
|
2824
|
+
|
|
2825
|
+
if isinstance(yl_text, str) and yl_text.strip():
|
|
2826
|
+
try:
|
|
2827
|
+
kwargs = dict(fontsize=int(fontsize), loc="top", rotation="horizontal")
|
|
2828
|
+
if yl_pad is not None:
|
|
2829
|
+
kwargs["labelpad"] = yl_pad
|
|
2830
|
+
ax.set_ylabel(yl_text, **kwargs)
|
|
2831
|
+
except Exception:
|
|
2832
|
+
ax.set_ylabel(yl_text, fontsize=int(fontsize))
|
|
2833
|
+
if isinstance(xl_text, str) and xl_text.strip():
|
|
2834
|
+
try:
|
|
2835
|
+
kwargs = dict(fontsize=int(fontsize), loc="right")
|
|
2836
|
+
if xl_pad is not None:
|
|
2837
|
+
kwargs["labelpad"] = xl_pad
|
|
2838
|
+
ax.set_xlabel(xl_text, **kwargs)
|
|
2839
|
+
except Exception:
|
|
2840
|
+
ax.set_xlabel(xl_text, fontsize=int(fontsize))
|
|
2841
|
+
|
|
2842
|
+
# Apply user figsize at the very end if provided
|
|
2843
|
+
if parsed_figsize is not None:
|
|
2844
|
+
try:
|
|
2845
|
+
fig.set_size_inches(*parsed_figsize)
|
|
2846
|
+
except Exception:
|
|
2847
|
+
pass
|
|
2848
|
+
|
|
2849
|
+
# Handle individual tick control (xticks/yticks off)
|
|
2850
|
+
xticks_raw = merged.get("xticks")
|
|
2851
|
+
yticks_raw = merged.get("yticks")
|
|
2852
|
+
|
|
2853
|
+
if isinstance(xticks_raw, str) and xticks_raw.strip().lower() == "off":
|
|
2854
|
+
try:
|
|
2855
|
+
ax.set_xticks([])
|
|
2856
|
+
except Exception:
|
|
2857
|
+
pass
|
|
2858
|
+
|
|
2859
|
+
if isinstance(yticks_raw, str) and yticks_raw.strip().lower() == "off":
|
|
2860
|
+
try:
|
|
2861
|
+
ax.set_yticks([])
|
|
2862
|
+
except Exception:
|
|
2863
|
+
pass
|
|
2864
|
+
|
|
2865
|
+
# Apply tight_layout to prevent label clipping
|
|
2866
|
+
try:
|
|
2867
|
+
# Make sure text extents are realized before layout when using TeX
|
|
2868
|
+
try:
|
|
2869
|
+
fig.canvas.draw()
|
|
2870
|
+
except Exception:
|
|
2871
|
+
pass
|
|
2872
|
+
fig.tight_layout()
|
|
2873
|
+
except Exception:
|
|
2874
|
+
pass
|
|
2875
|
+
|
|
2876
|
+
fig.savefig(
|
|
2877
|
+
abs_svg,
|
|
2878
|
+
format="svg",
|
|
2879
|
+
transparent=True,
|
|
2880
|
+
)
|
|
2881
|
+
if debug_mode:
|
|
2882
|
+
# Sidecar PDF (optional for debugging)
|
|
2883
|
+
try:
|
|
2884
|
+
fig.savefig(
|
|
2885
|
+
os.path.join(abs_dir, f"{base_name}.pdf"),
|
|
2886
|
+
format="pdf",
|
|
2887
|
+
transparent=True,
|
|
2888
|
+
)
|
|
2889
|
+
except Exception:
|
|
2890
|
+
pass
|
|
2891
|
+
|
|
2892
|
+
matplotlib.pyplot.close(fig)
|
|
2893
|
+
# Restore rcParams modified for this figure
|
|
2894
|
+
try:
|
|
2895
|
+
matplotlib.rcParams["text.usetex"] = _old_usetex
|
|
2896
|
+
matplotlib.rcParams["mathtext.fontset"] = _old_mathtext
|
|
2897
|
+
except Exception:
|
|
2898
|
+
pass
|
|
2899
|
+
except Exception as e:
|
|
2900
|
+
# Best-effort rcParams restoration on error
|
|
2901
|
+
try:
|
|
2902
|
+
matplotlib.rcParams["text.usetex"] = _old_usetex
|
|
2903
|
+
matplotlib.rcParams["mathtext.fontset"] = _old_mathtext
|
|
2904
|
+
except Exception:
|
|
2905
|
+
pass
|
|
2906
|
+
return [
|
|
2907
|
+
self.state_machine.reporter.error(
|
|
2908
|
+
f"Feil under generering av figur: {e}", line=self.lineno
|
|
2909
|
+
)
|
|
2910
|
+
]
|
|
2911
|
+
|
|
2912
|
+
if not os.path.exists(abs_svg):
|
|
2913
|
+
return [self.state_machine.reporter.error("plot: SVG mangler.", line=self.lineno)]
|
|
2914
|
+
|
|
2915
|
+
env.note_dependency(abs_svg)
|
|
2916
|
+
# copy into build _static
|
|
2917
|
+
try:
|
|
2918
|
+
out_static = os.path.join(app.outdir, "_static", "plot")
|
|
2919
|
+
os.makedirs(out_static, exist_ok=True)
|
|
2920
|
+
shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
|
|
2921
|
+
except Exception:
|
|
2922
|
+
pass
|
|
2923
|
+
|
|
2924
|
+
try:
|
|
2925
|
+
raw_svg = open(abs_svg, "r", encoding="utf-8").read()
|
|
2926
|
+
except Exception as e:
|
|
2927
|
+
return [
|
|
2928
|
+
self.state_machine.reporter.error(
|
|
2929
|
+
f"plot inline: kunne ikke lese SVG: {e}", line=self.lineno
|
|
2930
|
+
)
|
|
2931
|
+
]
|
|
2932
|
+
|
|
2933
|
+
if not debug_mode and "viewBox" in raw_svg:
|
|
2934
|
+
raw_svg = _strip_root_svg_size(raw_svg)
|
|
2935
|
+
|
|
2936
|
+
if not debug_mode:
|
|
2937
|
+
raw_svg = _rewrite_ids(raw_svg, f"cpl_{content_hash}_{uuid.uuid4().hex[:6]}_")
|
|
2938
|
+
|
|
2939
|
+
alt_default = "Tilpasset figur"
|
|
2940
|
+
alt = merged.get("alt", alt_default)
|
|
2941
|
+
|
|
2942
|
+
width_opt = merged.get("width")
|
|
2943
|
+
percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
|
|
2944
|
+
|
|
2945
|
+
def _augment(m):
|
|
2946
|
+
tag = m.group(0)
|
|
2947
|
+
if "class=" not in tag:
|
|
2948
|
+
tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
|
|
2949
|
+
else:
|
|
2950
|
+
tag = tag.replace('class="', 'class="graph-inline-svg ')
|
|
2951
|
+
if alt and "aria-label=" not in tag:
|
|
2952
|
+
tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
|
|
2953
|
+
if width_opt:
|
|
2954
|
+
if percent:
|
|
2955
|
+
wval = width_opt.strip()
|
|
2956
|
+
else:
|
|
2957
|
+
wval = width_opt.strip()
|
|
2958
|
+
if wval.isdigit():
|
|
2959
|
+
wval += "px"
|
|
2960
|
+
style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
|
|
2961
|
+
if "style=" in tag:
|
|
2962
|
+
tag = re.sub(
|
|
2963
|
+
r'style="([^"]*)"',
|
|
2964
|
+
lambda mm: f'style="{mm.group(1)}; {style_frag}"',
|
|
2965
|
+
tag,
|
|
2966
|
+
count=1,
|
|
2967
|
+
)
|
|
2968
|
+
else:
|
|
2969
|
+
tag = tag[:-1] + f' style="{style_frag}"' + ">"
|
|
2970
|
+
return tag
|
|
2971
|
+
|
|
2972
|
+
raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
|
|
2973
|
+
# Deliberately do not inject a <title> element: browsers display it as a tooltip
|
|
2974
|
+
# on hover which is distracting for readers. Accessibility is still ensured via
|
|
2975
|
+
# role="img" and aria-label attributes already added in _augment(). If a title
|
|
2976
|
+
# is ever desired for a specific figure, that can be added manually after build
|
|
2977
|
+
# or a future directive option could re-enable this behavior.
|
|
2978
|
+
|
|
2979
|
+
figure = nodes.figure()
|
|
2980
|
+
figure.setdefault("classes", []).extend(["adaptive-figure", "plot-figure", "no-click"])
|
|
2981
|
+
raw_node = nodes.raw("", raw_svg, format="html")
|
|
2982
|
+
raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
|
|
2983
|
+
figure += raw_node
|
|
2984
|
+
|
|
2985
|
+
extra_classes = merged.get("class")
|
|
2986
|
+
if extra_classes:
|
|
2987
|
+
figure["classes"].extend(extra_classes)
|
|
2988
|
+
figure["align"] = merged.get("align", "center")
|
|
2989
|
+
|
|
2990
|
+
caption_lines = list(self.content)[caption_idx:]
|
|
2991
|
+
while caption_lines and not caption_lines[0].strip():
|
|
2992
|
+
caption_lines.pop(0)
|
|
2993
|
+
if caption_lines:
|
|
2994
|
+
caption = nodes.caption()
|
|
2995
|
+
caption += nodes.Text("\n".join(caption_lines))
|
|
2996
|
+
figure += caption
|
|
2997
|
+
|
|
2998
|
+
if explicit_name:
|
|
2999
|
+
self.add_name(figure)
|
|
3000
|
+
return [figure]
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
def setup(app): # pragma: no cover
|
|
3004
|
+
app.add_directive("plot", PlotDirective)
|
|
3005
|
+
# Ensure our figure CSS is linked even if the root package setup did not run first
|
|
3006
|
+
try:
|
|
3007
|
+
app.add_css_file("munchboka/css/general_style.css")
|
|
3008
|
+
except Exception:
|
|
3009
|
+
pass
|
|
3010
|
+
# Global default for LaTeX usage in plots; can be overridden per-figure via 'usetex:'
|
|
3011
|
+
app.add_config_value("plot_default_usetex", True, "env")
|
|
3012
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|