ilmplot 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.
- ilmplot/__init__.py +47 -0
- ilmplot/bridge.py +1469 -0
- ilmplot/document.py +2064 -0
- ilmplot/mathtext.py +69 -0
- ilmplot/messages.py +40 -0
- ilmplot/render.py +2574 -0
- ilmplot/stats.py +103 -0
- ilmplot-0.1.0.dist-info/METADATA +127 -0
- ilmplot-0.1.0.dist-info/RECORD +12 -0
- ilmplot-0.1.0.dist-info/WHEEL +5 -0
- ilmplot-0.1.0.dist-info/licenses/LICENSE +201 -0
- ilmplot-0.1.0.dist-info/top_level.txt +1 -0
ilmplot/bridge.py
ADDED
|
@@ -0,0 +1,1469 @@
|
|
|
1
|
+
"""Bridge a Matplotlib ``Figure`` into a :class:`PlotDocument`.
|
|
2
|
+
|
|
3
|
+
This is a *supported-data/style* import, not a pixel-identical
|
|
4
|
+
reconstruction: one rectilinear ``matplotlib.axes.Axes`` whose lines,
|
|
5
|
+
uniform scatter series, error bars, grouped/stacked bars, text notes,
|
|
6
|
+
ticks, spines, grid and legend map onto the document model is captured
|
|
7
|
+
exactly; harmless normalisations are reported in ``notes``; features the
|
|
8
|
+
document cannot express are collected in ``reasons`` and produce either
|
|
9
|
+
no document (:func:`convert`), a :class:`FallbackWarning` plain-SVG file
|
|
10
|
+
(:func:`savefig`), or :class:`UnsupportedFigureError` (strict mode and
|
|
11
|
+
the legacy :func:`document_from_figure`/:func:`export_figure` API).
|
|
12
|
+
|
|
13
|
+
Nothing about the caller's figure, ``rcParams`` or canvas is mutated.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
import warnings
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from .document import (
|
|
25
|
+
MARKERS, MAX_ANNOTATIONS, MAX_SERIES, MAX_TOTAL_POINTS, Annotation,
|
|
26
|
+
AxisStyle, Band, FrameStyle, GridStyle, LegendStyle, LineSeries,
|
|
27
|
+
MAX_SPANS, PlotDocument, PlotDocumentError, Span, StackCategory,
|
|
28
|
+
StackOptions, TextStyle, TitleStyle, LINESTYLES,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnsupportedFigureError(PlotDocumentError):
|
|
33
|
+
"""Raised when a figure uses features the editor cannot represent."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message, reasons=()):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.reasons = list(reasons)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FallbackWarning(UserWarning):
|
|
41
|
+
"""Emitted when savefig writes a plain SVG instead of a native file."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ConversionResult:
|
|
46
|
+
document: 'PlotDocument | None' = None
|
|
47
|
+
reasons: list = field(default_factory=list)
|
|
48
|
+
notes: list = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SaveResult:
|
|
53
|
+
path: str = ''
|
|
54
|
+
native: bool = False
|
|
55
|
+
reasons: list = field(default_factory=list)
|
|
56
|
+
notes: list = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_LEGEND_LOC_CODES = {
|
|
60
|
+
0: 'best', 1: 'upper right', 2: 'upper left', 3: 'lower left',
|
|
61
|
+
4: 'lower right', 5: 'right', 6: 'center left', 7: 'center right',
|
|
62
|
+
8: 'lower center', 9: 'upper center', 10: 'center',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_MARKER_MAP = {None: '', 'None': '', '': '',
|
|
66
|
+
'o': 'o', 's': 's', '^': '^', 'v': 'v',
|
|
67
|
+
'D': 'D', '+': '+', 'x': 'x', '.': '.'}
|
|
68
|
+
|
|
69
|
+
_REL_TOL = 1e-9
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_size_points(size) -> float:
|
|
73
|
+
"""Resolve a matplotlib font size (pt or named) to points."""
|
|
74
|
+
if isinstance(size, (int, float)) and not isinstance(size, bool):
|
|
75
|
+
return float(size)
|
|
76
|
+
import matplotlib
|
|
77
|
+
import matplotlib.font_manager
|
|
78
|
+
scalings = matplotlib.font_manager.font_scalings
|
|
79
|
+
try:
|
|
80
|
+
return float(matplotlib.rcParams['font.size']) \
|
|
81
|
+
* float(scalings[size])
|
|
82
|
+
except Exception:
|
|
83
|
+
return float(matplotlib.rcParams['font.size'])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _axis_converter(axis):
|
|
87
|
+
getter = getattr(axis, 'get_converter', None)
|
|
88
|
+
if getter is not None:
|
|
89
|
+
return getter()
|
|
90
|
+
return getattr(axis, 'converter', None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _to_hex(value, ctx=None):
|
|
94
|
+
"""'#rrggbb', or '#rrggbbaa' when the colour has real alpha."""
|
|
95
|
+
from matplotlib.colors import to_rgba
|
|
96
|
+
try:
|
|
97
|
+
r, g, b, a = to_rgba(value)
|
|
98
|
+
except Exception:
|
|
99
|
+
if ctx is not None:
|
|
100
|
+
raise _BridgeError(f"{ctx}: unsupported colour {value!r}")
|
|
101
|
+
raise
|
|
102
|
+
ch = lambda v: int(v * 255 + 0.5)
|
|
103
|
+
hexv = '#{:02x}{:02x}{:02x}'.format(ch(r), ch(g), ch(b))
|
|
104
|
+
if a < 1.0:
|
|
105
|
+
hexv += f'{ch(a):02x}'
|
|
106
|
+
return hexv
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _BridgeError(Exception):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _finite_1d(v, name):
|
|
114
|
+
"""Return a list of finite floats or raise _BridgeError."""
|
|
115
|
+
import numpy as np
|
|
116
|
+
if np.ma.isMaskedArray(v):
|
|
117
|
+
raise _BridgeError("masked data is not supported")
|
|
118
|
+
a = np.asarray(v)
|
|
119
|
+
if a.ndim != 1:
|
|
120
|
+
raise _BridgeError("only 1-D x/y data is supported")
|
|
121
|
+
if a.dtype.kind not in 'iuf':
|
|
122
|
+
try:
|
|
123
|
+
a = a.astype(float)
|
|
124
|
+
except (ValueError, TypeError):
|
|
125
|
+
raise _BridgeError(
|
|
126
|
+
f"only numeric x/y data is supported "
|
|
127
|
+
f"(got dtype {a.dtype})")
|
|
128
|
+
if a.dtype.kind not in 'iuf':
|
|
129
|
+
raise _BridgeError(
|
|
130
|
+
f"only numeric x/y data is supported "
|
|
131
|
+
f"(got dtype {a.dtype})")
|
|
132
|
+
a = np.asarray(a, dtype=float)
|
|
133
|
+
if a.size == 0 or not np.isfinite(a).all():
|
|
134
|
+
raise _BridgeError(f"{name} must contain only finite values")
|
|
135
|
+
return a.tolist()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _finite_pairs(x, y):
|
|
139
|
+
"""Return (xlist, ylist) or raise _BridgeError."""
|
|
140
|
+
xa = _finite_1d(x, 'x')
|
|
141
|
+
ya = _finite_1d(y, 'y')
|
|
142
|
+
if len(xa) != len(ya):
|
|
143
|
+
raise _BridgeError(
|
|
144
|
+
"x and y must be non-empty arrays of equal length")
|
|
145
|
+
return xa, ya
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _font_family_of(text_obj) -> str:
|
|
149
|
+
"""The resolved real family name for a text artist."""
|
|
150
|
+
import matplotlib.font_manager
|
|
151
|
+
prop = text_obj.get_fontproperties()
|
|
152
|
+
try:
|
|
153
|
+
path = matplotlib.font_manager.findfont(
|
|
154
|
+
prop, fallback_to_default=False)
|
|
155
|
+
except Exception:
|
|
156
|
+
path = matplotlib.font_manager.findfont(prop)
|
|
157
|
+
return matplotlib.font_manager.get_font(path).family_name
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_bold(fp) -> bool:
|
|
161
|
+
w = fp.get_weight()
|
|
162
|
+
if isinstance(w, str):
|
|
163
|
+
return w in ('bold', 'semibold', 'demibold', 'heavy', 'black',
|
|
164
|
+
'extra bold', 'ultrabold')
|
|
165
|
+
try:
|
|
166
|
+
return float(w) >= 600
|
|
167
|
+
except Exception:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_italic(fp) -> bool:
|
|
172
|
+
return fp.get_style() in ('italic', 'oblique')
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _text_style(text_obj, base_size, base_family, cls=TextStyle):
|
|
176
|
+
"""TextStyle with only the fields differing from the defaults."""
|
|
177
|
+
fp = text_obj.get_fontproperties()
|
|
178
|
+
st = cls()
|
|
179
|
+
size = _resolve_size_points(text_obj.get_size())
|
|
180
|
+
if not math.isclose(size, base_size, rel_tol=1e-6):
|
|
181
|
+
st.size_pt = size
|
|
182
|
+
try:
|
|
183
|
+
color = _to_hex(text_obj.get_color())
|
|
184
|
+
if color != '#000000':
|
|
185
|
+
st.color = color
|
|
186
|
+
except _BridgeError:
|
|
187
|
+
pass
|
|
188
|
+
if _is_bold(fp):
|
|
189
|
+
st.bold = True
|
|
190
|
+
if _is_italic(fp):
|
|
191
|
+
st.italic = True
|
|
192
|
+
fam = _font_family_of(text_obj)
|
|
193
|
+
if fam != base_family:
|
|
194
|
+
st.family = fam
|
|
195
|
+
return st
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _Converter:
|
|
199
|
+
def __init__(self, figure):
|
|
200
|
+
self.fig = figure
|
|
201
|
+
self.reasons = []
|
|
202
|
+
self.notes = []
|
|
203
|
+
self.doc = PlotDocument()
|
|
204
|
+
self.doc.series = []
|
|
205
|
+
self.doc.legend = False
|
|
206
|
+
self.ax = None
|
|
207
|
+
self._base_family = 'DejaVu Sans'
|
|
208
|
+
self._base_size = 10.0
|
|
209
|
+
self._line_yerr = {} # Line2D -> (kind, yerr tuple)
|
|
210
|
+
self._line_label = {} # id(Line2D) -> container label
|
|
211
|
+
self._owned_collections = set() # errorbar LineCollections
|
|
212
|
+
self._bar_yerr = {} # id(BarContainer) -> (lo, hi)
|
|
213
|
+
self._bar_containers = []
|
|
214
|
+
self._errbar_containers = []
|
|
215
|
+
|
|
216
|
+
def reason(self, msg):
|
|
217
|
+
self.reasons.append(msg)
|
|
218
|
+
|
|
219
|
+
def note(self, msg):
|
|
220
|
+
self.notes.append(msg)
|
|
221
|
+
|
|
222
|
+
def run(self) -> ConversionResult:
|
|
223
|
+
fig = self.fig
|
|
224
|
+
try:
|
|
225
|
+
fig.draw_without_rendering()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
self._check_figure_level()
|
|
229
|
+
axes = list(fig.get_axes())
|
|
230
|
+
if len(axes) != 1:
|
|
231
|
+
self.reason(
|
|
232
|
+
f"{len(axes)} axes (subplots/twin/inset/colorbar "
|
|
233
|
+
"not supported)")
|
|
234
|
+
return ConversionResult(None, self.reasons, self.notes)
|
|
235
|
+
ax = axes[0]
|
|
236
|
+
import matplotlib.axes
|
|
237
|
+
if type(ax) is not matplotlib.axes.Axes:
|
|
238
|
+
self.reason(
|
|
239
|
+
f"only standard rectilinear axes are supported "
|
|
240
|
+
f"(got {type(ax).__name__})")
|
|
241
|
+
return ConversionResult(None, self.reasons, self.notes)
|
|
242
|
+
self.ax = ax
|
|
243
|
+
self._check_axes_level()
|
|
244
|
+
self._collect_containers()
|
|
245
|
+
self._map_geometry()
|
|
246
|
+
self._map_fonts()
|
|
247
|
+
self._map_scales_ticks()
|
|
248
|
+
self._map_spines_grid()
|
|
249
|
+
self._map_misc()
|
|
250
|
+
self._map_lines()
|
|
251
|
+
self._map_scatter()
|
|
252
|
+
self._map_bars()
|
|
253
|
+
self._map_patches()
|
|
254
|
+
self._map_texts()
|
|
255
|
+
self._map_legend()
|
|
256
|
+
self._map_limits()
|
|
257
|
+
if self.reasons:
|
|
258
|
+
return ConversionResult(None, self.reasons, self.notes)
|
|
259
|
+
try:
|
|
260
|
+
self.doc.validate()
|
|
261
|
+
except PlotDocumentError as e:
|
|
262
|
+
self.reason(f"the mapped document is invalid: {e}")
|
|
263
|
+
return ConversionResult(None, self.reasons, self.notes)
|
|
264
|
+
return ConversionResult(self.doc, [], self.notes)
|
|
265
|
+
|
|
266
|
+
# ── top-level rejection checks ───────────────────────────────────
|
|
267
|
+
def _check_figure_level(self):
|
|
268
|
+
fig = self.fig
|
|
269
|
+
for name in ('texts', 'artists', 'images', 'lines', 'patches'):
|
|
270
|
+
if list(getattr(fig, name, []) or []):
|
|
271
|
+
self.reason(
|
|
272
|
+
f"figure-level {name} (figtext/suptitle/figimage) "
|
|
273
|
+
"are not supported")
|
|
274
|
+
if getattr(fig, 'legends', None):
|
|
275
|
+
self.reason("figure-level legends are not supported")
|
|
276
|
+
|
|
277
|
+
def _check_axes_level(self):
|
|
278
|
+
ax = self.ax
|
|
279
|
+
if ax.images:
|
|
280
|
+
self.reason("axes images (imshow/pcolormesh) are not "
|
|
281
|
+
"supported")
|
|
282
|
+
if ax.artists:
|
|
283
|
+
self.reason("free axes artists are not supported")
|
|
284
|
+
if list(getattr(ax, 'child_axes', []) or []):
|
|
285
|
+
self.reason("child/inset axes are not supported")
|
|
286
|
+
if list(getattr(ax, 'tables', []) or []):
|
|
287
|
+
self.reason("axes tables are not supported")
|
|
288
|
+
titles = [(loc, ax.get_title(loc=loc))
|
|
289
|
+
for loc in ('left', 'center', 'right')]
|
|
290
|
+
used = [(loc, t) for loc, t in titles if t]
|
|
291
|
+
if len(used) > 1:
|
|
292
|
+
self.reason("multiple axes titles are not supported")
|
|
293
|
+
self._title_loc = used[0][0] if used else 'center'
|
|
294
|
+
conv_x = _axis_converter(ax.xaxis)
|
|
295
|
+
conv_y = _axis_converter(ax.yaxis)
|
|
296
|
+
self._categorical = type(conv_x).__name__ == 'StrCategoryConverter'
|
|
297
|
+
if conv_x is not None and not self._categorical:
|
|
298
|
+
self.reason(
|
|
299
|
+
f"x-axis unit/date converter "
|
|
300
|
+
f"{type(conv_x).__name__} is not supported")
|
|
301
|
+
if conv_y is not None:
|
|
302
|
+
self.reason(
|
|
303
|
+
f"y-axis unit/date converter "
|
|
304
|
+
f"{type(conv_y).__name__} is not supported")
|
|
305
|
+
|
|
306
|
+
def _collect_containers(self):
|
|
307
|
+
from matplotlib.container import BarContainer, ErrorbarContainer
|
|
308
|
+
for c in self.ax.containers:
|
|
309
|
+
if isinstance(c, ErrorbarContainer):
|
|
310
|
+
self._errbar_containers.append(c)
|
|
311
|
+
elif isinstance(c, BarContainer):
|
|
312
|
+
self._bar_containers.append(c)
|
|
313
|
+
else:
|
|
314
|
+
self.reason(
|
|
315
|
+
f"{type(c).__name__} containers are not supported")
|
|
316
|
+
bar_owned = {id(getattr(b, 'errorbar', None))
|
|
317
|
+
for b in self._bar_containers}
|
|
318
|
+
for c in self._errbar_containers:
|
|
319
|
+
if id(c) in bar_owned:
|
|
320
|
+
continue
|
|
321
|
+
self._scan_errorbar(c, owner=None)
|
|
322
|
+
for c in self._bar_containers:
|
|
323
|
+
err = getattr(c, 'errorbar', None)
|
|
324
|
+
if err is not None:
|
|
325
|
+
self._scan_errorbar(err, owner=c)
|
|
326
|
+
capline_ids = set()
|
|
327
|
+
for c in self._errbar_containers + [getattr(b, 'errorbar', None)
|
|
328
|
+
for b in self._bar_containers]:
|
|
329
|
+
if c is None:
|
|
330
|
+
continue
|
|
331
|
+
try:
|
|
332
|
+
_line, caplines, barcols = c.lines
|
|
333
|
+
except Exception:
|
|
334
|
+
continue
|
|
335
|
+
capline_ids.update(id(cl) for cl in caplines)
|
|
336
|
+
self._owned_collections.update(id(lc) for lc in barcols)
|
|
337
|
+
self._caplines = capline_ids
|
|
338
|
+
|
|
339
|
+
def _scan_errorbar(self, container, owner):
|
|
340
|
+
"""Validate an ErrorbarContainer and remember its y-errors."""
|
|
341
|
+
ctx = 'errorbar'
|
|
342
|
+
try:
|
|
343
|
+
data_line, caplines, barcols = container.lines
|
|
344
|
+
except Exception as e:
|
|
345
|
+
self.reason(f"{ctx}: unreadable container ({e})")
|
|
346
|
+
return
|
|
347
|
+
if data_line is None and owner is None:
|
|
348
|
+
self.reason("errorbar without a data line (fmt='none') is "
|
|
349
|
+
"not supported")
|
|
350
|
+
return
|
|
351
|
+
if caplines:
|
|
352
|
+
self.note("error-bar caps dropped")
|
|
353
|
+
if owner is None:
|
|
354
|
+
base_lw = data_line.get_linewidth()
|
|
355
|
+
base_color = None
|
|
356
|
+
try:
|
|
357
|
+
base_color = _to_hex(data_line.get_color())
|
|
358
|
+
except _BridgeError:
|
|
359
|
+
pass
|
|
360
|
+
else:
|
|
361
|
+
base_lw = None
|
|
362
|
+
base_color = None
|
|
363
|
+
minus = plus = None
|
|
364
|
+
for lc in barcols:
|
|
365
|
+
segs = lc.get_segments()
|
|
366
|
+
vertical = horizontal = False
|
|
367
|
+
for seg in segs:
|
|
368
|
+
a, b = seg[0], seg[1]
|
|
369
|
+
if math.isclose(float(a[0]), float(b[0]),
|
|
370
|
+
abs_tol=1e-12, rel_tol=0):
|
|
371
|
+
vertical = True
|
|
372
|
+
elif math.isclose(float(a[1]), float(b[1]),
|
|
373
|
+
abs_tol=1e-12, rel_tol=0):
|
|
374
|
+
horizontal = True
|
|
375
|
+
else:
|
|
376
|
+
self.reason(f"{ctx}: non-axis-aligned error bars "
|
|
377
|
+
"are not supported")
|
|
378
|
+
return
|
|
379
|
+
if horizontal:
|
|
380
|
+
self.reason("xerr (horizontal error bars) is not "
|
|
381
|
+
"supported")
|
|
382
|
+
return
|
|
383
|
+
if vertical:
|
|
384
|
+
lo = [float(min(a[1], b[1])) for a, b in
|
|
385
|
+
[(s[0], s[1]) for s in segs]]
|
|
386
|
+
hi = [float(max(a[1], b[1])) for a, b in
|
|
387
|
+
[(s[0], s[1]) for s in segs]]
|
|
388
|
+
minus, plus = lo, hi
|
|
389
|
+
if minus is not None:
|
|
390
|
+
if owner is None:
|
|
391
|
+
self._line_yerr[id(data_line)] = (minus, plus)
|
|
392
|
+
else:
|
|
393
|
+
self._bar_yerr[id(owner)] = (minus, plus)
|
|
394
|
+
if owner is None and data_line is not None:
|
|
395
|
+
# The label lives on the container — the data Line2D
|
|
396
|
+
# carries '_nolegend_'.
|
|
397
|
+
self._line_label[id(data_line)] = \
|
|
398
|
+
container.get_label() or ''
|
|
399
|
+
lc0 = barcols[0] if barcols else None
|
|
400
|
+
if lc0 is not None:
|
|
401
|
+
try:
|
|
402
|
+
ecolor = _to_hex(lc0.get_color())
|
|
403
|
+
if base_color is not None and ecolor != base_color:
|
|
404
|
+
self.note("error-bar colour differs from the "
|
|
405
|
+
"series colour")
|
|
406
|
+
except _BridgeError:
|
|
407
|
+
pass
|
|
408
|
+
try:
|
|
409
|
+
elw = float(lc0.get_linewidths()[0])
|
|
410
|
+
if not math.isclose(elw, float(base_lw),
|
|
411
|
+
rel_tol=1e-6):
|
|
412
|
+
self.note("error-bar line width differs from "
|
|
413
|
+
"the series")
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
# ── geometry / fonts ─────────────────────────────────────────────
|
|
418
|
+
def _map_geometry(self):
|
|
419
|
+
fig, ax, doc = self.fig, self.ax, self.doc
|
|
420
|
+
doc.width_mm = fig.get_figwidth() * 25.4
|
|
421
|
+
doc.height_mm = fig.get_figheight() * 25.4
|
|
422
|
+
l, b, w, h = ax.get_position().bounds
|
|
423
|
+
rect = [float(l), float(b), float(w), float(h)]
|
|
424
|
+
clamped = [min(max(rect[0], 0.0), 0.99), min(max(rect[1], 0.0),
|
|
425
|
+
0.99),
|
|
426
|
+
min(max(rect[2], 1e-6), 1.0),
|
|
427
|
+
min(max(rect[3], 1e-6), 1.0)]
|
|
428
|
+
clamped[2] = min(clamped[2], 1.0 - clamped[0])
|
|
429
|
+
clamped[3] = min(clamped[3], 1.0 - clamped[1])
|
|
430
|
+
if clamped != rect:
|
|
431
|
+
self.note("axes position re-fitted into the figure bounds")
|
|
432
|
+
doc.axes_rect = clamped
|
|
433
|
+
doc.title = next(
|
|
434
|
+
(ax.get_title(loc=loc)
|
|
435
|
+
for loc in ('left', 'center', 'right')
|
|
436
|
+
if ax.get_title(loc=loc)), '')
|
|
437
|
+
doc.xlabel = ax.get_xlabel() or ''
|
|
438
|
+
doc.ylabel = ax.get_ylabel() or ''
|
|
439
|
+
try:
|
|
440
|
+
doc.xlim = [float(v) for v in ax.get_xlim()]
|
|
441
|
+
doc.ylim = [float(v) for v in ax.get_ylim()]
|
|
442
|
+
for lim in (doc.xlim, doc.ylim):
|
|
443
|
+
if not all(map(math.isfinite, lim)) or lim[0] == lim[1]:
|
|
444
|
+
doc.xlim = doc.ylim = None
|
|
445
|
+
raise ValueError
|
|
446
|
+
except Exception:
|
|
447
|
+
doc.xlim = doc.ylim = None
|
|
448
|
+
self.reason("axis limits are not finite")
|
|
449
|
+
|
|
450
|
+
def _map_fonts(self):
|
|
451
|
+
ax, doc = self.ax, self.doc
|
|
452
|
+
xticks = ax.get_xticklabels()
|
|
453
|
+
sample = xticks[0] if xticks else None
|
|
454
|
+
if sample is not None:
|
|
455
|
+
doc.font_family = _font_family_of(sample)
|
|
456
|
+
doc.font_size_pt = _resolve_size_points(sample.get_size())
|
|
457
|
+
else:
|
|
458
|
+
import matplotlib
|
|
459
|
+
doc.font_family = _font_family_of(ax.xaxis.label)
|
|
460
|
+
doc.font_size_pt = _resolve_size_points(
|
|
461
|
+
matplotlib.rcParams['xtick.labelsize'])
|
|
462
|
+
self._base_family = doc.font_family
|
|
463
|
+
self._base_size = doc.font_size_pt
|
|
464
|
+
title_obj = {'left': ax._left_title, 'center': ax.title,
|
|
465
|
+
'right': ax._right_title}[self._title_loc]
|
|
466
|
+
doc.title_size_pt = _resolve_size_points(title_obj.get_size())
|
|
467
|
+
ts = _text_style(title_obj, doc.font_size_pt,
|
|
468
|
+
doc.font_family, cls=TitleStyle)
|
|
469
|
+
# The title's own size maps to title_size_pt, not a style.
|
|
470
|
+
ts.size_pt = None
|
|
471
|
+
ts.align = self._title_loc
|
|
472
|
+
if ts.to_dict():
|
|
473
|
+
doc.style.title = ts
|
|
474
|
+
for name, text_obj in (('xlabel', ax.xaxis.label),
|
|
475
|
+
('ylabel', ax.yaxis.label)):
|
|
476
|
+
st = _text_style(text_obj, doc.font_size_pt, doc.font_family)
|
|
477
|
+
if st.to_dict():
|
|
478
|
+
setattr(doc.style, name, st)
|
|
479
|
+
if sample is not None:
|
|
480
|
+
st = _text_style(sample, doc.font_size_pt, doc.font_family)
|
|
481
|
+
st.size_pt = None # x tick size *is* the document default
|
|
482
|
+
if st.to_dict():
|
|
483
|
+
doc.style.xaxis = doc.style.xaxis or AxisStyle()
|
|
484
|
+
doc.style.xaxis.ticks = st
|
|
485
|
+
yticks = ax.get_yticklabels()
|
|
486
|
+
if yticks:
|
|
487
|
+
st = _text_style(yticks[0], doc.font_size_pt,
|
|
488
|
+
doc.font_family)
|
|
489
|
+
if st.to_dict():
|
|
490
|
+
doc.style.yaxis = doc.style.yaxis or AxisStyle()
|
|
491
|
+
doc.style.yaxis.ticks = st
|
|
492
|
+
|
|
493
|
+
def _map_scales_ticks(self):
|
|
494
|
+
ax, doc = self.ax, self.doc
|
|
495
|
+
for name, axis, scale in (('xaxis', ax.xaxis, ax.get_xscale()),
|
|
496
|
+
('yaxis', ax.yaxis, ax.get_yscale())):
|
|
497
|
+
st = getattr(doc.style, name) or AxisStyle()
|
|
498
|
+
if scale == 'linear':
|
|
499
|
+
pass
|
|
500
|
+
elif scale == 'log':
|
|
501
|
+
base = getattr(axis._scale, 'base', 10)
|
|
502
|
+
if float(base) == 10.0:
|
|
503
|
+
st.scale = 'log'
|
|
504
|
+
else:
|
|
505
|
+
self.reason(f"log axis with base {base} is not "
|
|
506
|
+
"supported (only base 10)")
|
|
507
|
+
else:
|
|
508
|
+
self.reason(f"{scale} axes are not supported "
|
|
509
|
+
f"({name[0]} axis)")
|
|
510
|
+
params = axis.get_tick_params(which='major')
|
|
511
|
+
direction = params.get('direction')
|
|
512
|
+
if direction is None:
|
|
513
|
+
import matplotlib
|
|
514
|
+
direction = matplotlib.rcParams.get(
|
|
515
|
+
f"{name[0]}tick.direction", 'out')
|
|
516
|
+
if direction in ('in', 'out', 'inout'):
|
|
517
|
+
st.direction = direction
|
|
518
|
+
length = params.get('length')
|
|
519
|
+
if length is None:
|
|
520
|
+
import matplotlib
|
|
521
|
+
length = matplotlib.rcParams.get(
|
|
522
|
+
f"{name[0]}tick.major.size", 3.5)
|
|
523
|
+
st.length_pt = float(length)
|
|
524
|
+
minor_loc = axis.get_minor_locator()
|
|
525
|
+
from matplotlib.ticker import NullLocator
|
|
526
|
+
if minor_loc is not None \
|
|
527
|
+
and not isinstance(minor_loc, NullLocator):
|
|
528
|
+
st.minor = True
|
|
529
|
+
self._map_locator(axis, st, name)
|
|
530
|
+
self._map_formatter(axis, st, name)
|
|
531
|
+
if st.to_dict():
|
|
532
|
+
setattr(doc.style, name, st)
|
|
533
|
+
xticks = ax.get_xticklabels()
|
|
534
|
+
if xticks:
|
|
535
|
+
rot = xticks[0].get_rotation()
|
|
536
|
+
if isinstance(rot, str):
|
|
537
|
+
rot = {'horizontal': 0.0, 'vertical': 90.0}.get(rot, 0.0)
|
|
538
|
+
rot = float(rot)
|
|
539
|
+
if -90.0 <= rot <= 90.0:
|
|
540
|
+
if rot:
|
|
541
|
+
st = doc.style.xaxis or AxisStyle()
|
|
542
|
+
st.rotation = rot
|
|
543
|
+
doc.style.xaxis = st
|
|
544
|
+
else:
|
|
545
|
+
self.reason(f"x tick label rotation {rot} is outside "
|
|
546
|
+
"-90..90")
|
|
547
|
+
|
|
548
|
+
def _map_locator(self, axis, st, name):
|
|
549
|
+
import matplotlib.ticker as mt
|
|
550
|
+
loc = axis.get_major_locator()
|
|
551
|
+
ok = (mt.AutoLocator, mt.MaxNLocator, mt.LinearLocator,
|
|
552
|
+
mt.LogLocator, mt.IndexLocator, mt.NullLocator)
|
|
553
|
+
if isinstance(loc, mt.MultipleLocator):
|
|
554
|
+
if st.scale == 'linear':
|
|
555
|
+
st.step = float(loc._edge.step)
|
|
556
|
+
else:
|
|
557
|
+
self.note("custom tick locator replaced by automatic "
|
|
558
|
+
f"on {name}")
|
|
559
|
+
elif isinstance(loc, ok):
|
|
560
|
+
pass
|
|
561
|
+
elif type(loc).__name__ == 'StrCategoryLocator':
|
|
562
|
+
pass
|
|
563
|
+
else:
|
|
564
|
+
self.note(f"custom tick locator "
|
|
565
|
+
f"{type(loc).__name__} replaced by automatic "
|
|
566
|
+
f"on {name}")
|
|
567
|
+
|
|
568
|
+
def _map_formatter(self, axis, st, name):
|
|
569
|
+
import matplotlib.ticker as mt
|
|
570
|
+
fmt = axis.get_major_formatter()
|
|
571
|
+
dec = None
|
|
572
|
+
if isinstance(fmt, mt.FormatStrFormatter):
|
|
573
|
+
import re
|
|
574
|
+
m = re.fullmatch(r'%\.(\d+)f', fmt.fmt)
|
|
575
|
+
if m:
|
|
576
|
+
dec = int(m.group(1))
|
|
577
|
+
else:
|
|
578
|
+
self.note(f"custom tick format {fmt.fmt!r} replaced by "
|
|
579
|
+
f"automatic on {name}")
|
|
580
|
+
elif isinstance(fmt, mt.StrMethodFormatter):
|
|
581
|
+
import re
|
|
582
|
+
m = re.fullmatch(r'\{x:\.(\d+)f\}', fmt.fmt)
|
|
583
|
+
if m:
|
|
584
|
+
dec = int(m.group(1))
|
|
585
|
+
elif fmt.fmt != '{x}':
|
|
586
|
+
self.note(f"custom tick format {fmt.fmt!r} replaced by "
|
|
587
|
+
f"automatic on {name}")
|
|
588
|
+
elif isinstance(fmt, (mt.ScalarFormatter, mt.NullFormatter,
|
|
589
|
+
mt.LogFormatter, mt.LogFormatterSciNotation,
|
|
590
|
+
mt.LogFormatterExponent,
|
|
591
|
+
mt.LogFormatterMathtext, mt.FixedFormatter)) \
|
|
592
|
+
or type(fmt).__name__ == 'StrCategoryFormatter':
|
|
593
|
+
pass
|
|
594
|
+
else:
|
|
595
|
+
self.note(f"custom tick formatter "
|
|
596
|
+
f"{type(fmt).__name__} replaced by automatic "
|
|
597
|
+
f"on {name}")
|
|
598
|
+
if dec is not None:
|
|
599
|
+
st.decimals = dec
|
|
600
|
+
|
|
601
|
+
def _map_misc(self):
|
|
602
|
+
from matplotlib.colors import to_rgba
|
|
603
|
+
for what, get in (('axes', self.ax.get_facecolor),
|
|
604
|
+
('figure', self.fig.get_facecolor)):
|
|
605
|
+
try:
|
|
606
|
+
r, g, b, a = to_rgba(get())
|
|
607
|
+
except Exception:
|
|
608
|
+
continue
|
|
609
|
+
if a > 0 and (r, g, b) != (1.0, 1.0, 1.0):
|
|
610
|
+
self.note(f"{what} background colour normalised")
|
|
611
|
+
if self.ax.xaxis.get_ticks_position() != 'bottom':
|
|
612
|
+
self.note("ticks on top/right dropped")
|
|
613
|
+
if self.ax.yaxis.get_ticks_position() != 'left':
|
|
614
|
+
self.note("ticks on top/right dropped")
|
|
615
|
+
|
|
616
|
+
def _map_spines_grid(self):
|
|
617
|
+
ax, doc = self.ax, self.doc
|
|
618
|
+
spines = ax.spines
|
|
619
|
+
colors, widths = set(), set()
|
|
620
|
+
for side in ('top', 'right', 'left', 'bottom'):
|
|
621
|
+
visible = bool(spines[side].get_visible())
|
|
622
|
+
if not visible:
|
|
623
|
+
st = doc.style.frame or FrameStyle()
|
|
624
|
+
setattr(st, f'hide_{side}', True)
|
|
625
|
+
doc.style.frame = st
|
|
626
|
+
else:
|
|
627
|
+
try:
|
|
628
|
+
colors.add(_to_hex(spines[side].get_edgecolor()))
|
|
629
|
+
except _BridgeError:
|
|
630
|
+
pass
|
|
631
|
+
widths.add(round(float(spines[side].get_linewidth()), 6))
|
|
632
|
+
if len(colors) == 1 and len(widths) == 1:
|
|
633
|
+
st = doc.style.frame or FrameStyle()
|
|
634
|
+
st.color = colors.pop()
|
|
635
|
+
st.linewidth_pt = widths.pop()
|
|
636
|
+
if st.to_dict():
|
|
637
|
+
doc.style.frame = st
|
|
638
|
+
elif colors or widths:
|
|
639
|
+
self.note("spine colours/widths differ — the frame uses "
|
|
640
|
+
"the editor defaults")
|
|
641
|
+
gx = [t.gridline for t in ax.xaxis.get_major_ticks()
|
|
642
|
+
if t.gridline.get_visible()]
|
|
643
|
+
gy = [t.gridline for t in ax.yaxis.get_major_ticks()
|
|
644
|
+
if t.gridline.get_visible()]
|
|
645
|
+
gminor = [t.gridline for t in
|
|
646
|
+
ax.xaxis.get_minor_ticks() + ax.yaxis.get_minor_ticks()
|
|
647
|
+
if t.gridline.get_visible()]
|
|
648
|
+
visible = gx + gy
|
|
649
|
+
if visible:
|
|
650
|
+
doc.grid = True
|
|
651
|
+
gs = GridStyle()
|
|
652
|
+
gs.axis = 'both' if (gx and gy) else ('x' if gx else 'y')
|
|
653
|
+
if gminor:
|
|
654
|
+
gs.which = 'both'
|
|
655
|
+
g = visible[0]
|
|
656
|
+
try:
|
|
657
|
+
gs.color = _to_hex(g.get_color())
|
|
658
|
+
except _BridgeError:
|
|
659
|
+
pass
|
|
660
|
+
ls = g.get_linestyle()
|
|
661
|
+
if ls in _GRID_LINESTYLES_OK:
|
|
662
|
+
gs.linestyle = ls
|
|
663
|
+
lw = float(g.get_linewidth())
|
|
664
|
+
if 0 < lw <= 20:
|
|
665
|
+
gs.linewidth_pt = lw
|
|
666
|
+
alpha = g.get_alpha()
|
|
667
|
+
if alpha is not None:
|
|
668
|
+
gs.alpha = float(alpha)
|
|
669
|
+
doc.style.grid = gs
|
|
670
|
+
|
|
671
|
+
# ── series ───────────────────────────────────────────────────────
|
|
672
|
+
def _map_lines(self):
|
|
673
|
+
ax, doc = self.ax, self.doc
|
|
674
|
+
lines = [l for l in ax.get_lines()
|
|
675
|
+
if id(l) not in self._caplines]
|
|
676
|
+
for i, line in enumerate(lines):
|
|
677
|
+
ctx = f"series[{i}]"
|
|
678
|
+
try:
|
|
679
|
+
self._map_line(line, ctx)
|
|
680
|
+
except _BridgeError as e:
|
|
681
|
+
self.reason(f"{ctx}: {e}")
|
|
682
|
+
|
|
683
|
+
def _map_line(self, line, ctx):
|
|
684
|
+
ax = self.ax
|
|
685
|
+
if not line.get_visible():
|
|
686
|
+
raise _BridgeError("hidden lines cannot be represented")
|
|
687
|
+
if line.get_transform() is not ax.transData:
|
|
688
|
+
raise _BridgeError(
|
|
689
|
+
"non-data transforms (axhline/axvline/…) are not "
|
|
690
|
+
"supported")
|
|
691
|
+
if line.get_drawstyle() != 'default':
|
|
692
|
+
raise _BridgeError(
|
|
693
|
+
f"drawstyle {line.get_drawstyle()!r} is not supported")
|
|
694
|
+
if line.get_markevery() not in (None, 1):
|
|
695
|
+
raise _BridgeError("markevery is not supported")
|
|
696
|
+
if getattr(line, 'get_path_effects', lambda: [])():
|
|
697
|
+
raise _BridgeError("path effects are not supported")
|
|
698
|
+
ls = line.get_linestyle()
|
|
699
|
+
if ls in ('None', ''):
|
|
700
|
+
ls = ''
|
|
701
|
+
elif isinstance(ls, tuple) or ls not in LINESTYLES:
|
|
702
|
+
raise _BridgeError(
|
|
703
|
+
f"custom dash pattern {ls!r} is not supported")
|
|
704
|
+
elif ls:
|
|
705
|
+
from matplotlib.lines import Line2D
|
|
706
|
+
default = Line2D([0], [0], linestyle=ls)._unscaled_dash_pattern
|
|
707
|
+
if tuple(line._unscaled_dash_pattern) != tuple(default):
|
|
708
|
+
raise _BridgeError("custom dash pattern is not supported")
|
|
709
|
+
mk = _MARKER_MAP.get(line.get_marker())
|
|
710
|
+
if mk is None:
|
|
711
|
+
raise _BridgeError(
|
|
712
|
+
f"marker {line.get_marker()!r} is not supported")
|
|
713
|
+
if mk:
|
|
714
|
+
self._check_marker_colors(line, ctx)
|
|
715
|
+
try:
|
|
716
|
+
x, y = _finite_pairs(
|
|
717
|
+
line.get_xdata(orig=True), line.get_ydata(orig=True))
|
|
718
|
+
except _BridgeError:
|
|
719
|
+
if not self._categorical:
|
|
720
|
+
raise
|
|
721
|
+
y = _finite_1d(line.get_ydata(orig=True), 'y')
|
|
722
|
+
x = self._category_positions(line.get_xdata(orig=True))
|
|
723
|
+
if len(x) != len(y):
|
|
724
|
+
raise _BridgeError(
|
|
725
|
+
"x and y must be non-empty arrays of equal length")
|
|
726
|
+
label = line.get_label() or ''
|
|
727
|
+
if not label or label.startswith('_'):
|
|
728
|
+
# e.g. errorbar data lines: the container owns the label.
|
|
729
|
+
label = self._line_label.get(id(line), '') or ''
|
|
730
|
+
if label.startswith('_'):
|
|
731
|
+
label = ''
|
|
732
|
+
s = LineSeries(
|
|
733
|
+
label=label, x=x, y=y,
|
|
734
|
+
linewidth_pt=float(line.get_linewidth()),
|
|
735
|
+
linestyle=ls, marker=mk,
|
|
736
|
+
markersize_pt=float(line.get_markersize()),
|
|
737
|
+
)
|
|
738
|
+
s.color = _to_hex(line.get_color(), ctx)
|
|
739
|
+
bounds = self._line_yerr.get(id(line))
|
|
740
|
+
if bounds is not None:
|
|
741
|
+
lo, hi = bounds
|
|
742
|
+
if len(lo) != len(y):
|
|
743
|
+
raise _BridgeError("error-bar count does not match the "
|
|
744
|
+
"data points")
|
|
745
|
+
s.yerr, s.yerr_minus, s.yerr_plus = _yerr_fields(y, lo, hi)
|
|
746
|
+
self.doc.series.append(s)
|
|
747
|
+
if self._categorical and self.doc.kind in ('line', 'ridgeline'):
|
|
748
|
+
labels = self._category_labels()
|
|
749
|
+
if labels is not None and self.doc.x_tick_labels is None:
|
|
750
|
+
mapping = self._category_mapping()
|
|
751
|
+
self.doc.x_tick_labels = [
|
|
752
|
+
[float(pos), str(lbl)] for lbl, pos in
|
|
753
|
+
sorted(mapping.items(), key=lambda kv: kv[1])]
|
|
754
|
+
|
|
755
|
+
def _category_mapping(self):
|
|
756
|
+
units = getattr(self.ax.xaxis, 'units', None)
|
|
757
|
+
mapping = getattr(units, '_mapping', None)
|
|
758
|
+
if not mapping:
|
|
759
|
+
raise _BridgeError("categorical x positions are "
|
|
760
|
+
"unreadable")
|
|
761
|
+
return mapping
|
|
762
|
+
|
|
763
|
+
def _category_positions(self, values):
|
|
764
|
+
mapping = self._category_mapping()
|
|
765
|
+
out = []
|
|
766
|
+
for v in list(values):
|
|
767
|
+
key = str(v)
|
|
768
|
+
hit = None
|
|
769
|
+
for k, pos in mapping.items():
|
|
770
|
+
if str(k) == key:
|
|
771
|
+
hit = pos
|
|
772
|
+
break
|
|
773
|
+
if hit is None:
|
|
774
|
+
raise _BridgeError(
|
|
775
|
+
f"unmapped category {key!r} on the x axis")
|
|
776
|
+
out.append(float(hit))
|
|
777
|
+
return out
|
|
778
|
+
|
|
779
|
+
def _check_marker_colors(self, line, ctx):
|
|
780
|
+
from matplotlib.colors import to_rgba
|
|
781
|
+
if line.get_fillstyle() != 'full':
|
|
782
|
+
raise _BridgeError(
|
|
783
|
+
f"marker fillstyle {line.get_fillstyle()!r} is not "
|
|
784
|
+
"supported (only fully filled markers)")
|
|
785
|
+
line_rgba = to_rgba(line.get_color())
|
|
786
|
+
for attr in ('get_markeredgecolor', 'get_markerfacecolor'):
|
|
787
|
+
value = getattr(line, attr)()
|
|
788
|
+
if value == 'auto':
|
|
789
|
+
continue
|
|
790
|
+
if value == 'none':
|
|
791
|
+
if line_rgba[3] == 0:
|
|
792
|
+
continue
|
|
793
|
+
raise _BridgeError(
|
|
794
|
+
f"hollow marker {attr[4:]}='none' is not supported")
|
|
795
|
+
try:
|
|
796
|
+
if to_rgba(value) == line_rgba:
|
|
797
|
+
continue
|
|
798
|
+
except Exception:
|
|
799
|
+
pass
|
|
800
|
+
raise _BridgeError(
|
|
801
|
+
f"custom {attr[4:]} {value!r} is not supported")
|
|
802
|
+
|
|
803
|
+
def _map_scatter(self):
|
|
804
|
+
from matplotlib.collections import PathCollection, PolyCollection
|
|
805
|
+
owned = self._owned_collections
|
|
806
|
+
for coll in self.ax.collections:
|
|
807
|
+
if id(coll) in owned:
|
|
808
|
+
continue
|
|
809
|
+
if isinstance(coll, PathCollection):
|
|
810
|
+
try:
|
|
811
|
+
self._map_one_scatter(coll)
|
|
812
|
+
except _BridgeError as e:
|
|
813
|
+
self.reason(f"scatter: {e}")
|
|
814
|
+
continue
|
|
815
|
+
# fill_between/stackplot produce a FillBetweenPolyCollection
|
|
816
|
+
# (mpl >= 3.10) or a plain PolyCollection (older mpl). Both
|
|
817
|
+
# hold one closed polygon per contiguous band segment.
|
|
818
|
+
if isinstance(coll, PolyCollection):
|
|
819
|
+
try:
|
|
820
|
+
self._map_one_band(coll)
|
|
821
|
+
except _BridgeError as e:
|
|
822
|
+
self.reason(f"fill_between: {e}")
|
|
823
|
+
continue
|
|
824
|
+
self.reason(
|
|
825
|
+
f"axes collections ({type(coll).__name__} — "
|
|
826
|
+
"fill_between/contour/…) are not supported")
|
|
827
|
+
|
|
828
|
+
def _map_one_scatter(self, coll):
|
|
829
|
+
import numpy as np
|
|
830
|
+
from matplotlib.markers import MarkerStyle
|
|
831
|
+
if not coll.get_visible():
|
|
832
|
+
raise _BridgeError("hidden collections cannot be represented")
|
|
833
|
+
try:
|
|
834
|
+
probe = np.asarray([[0.1234, 0.5678]])
|
|
835
|
+
off_t = coll.get_offset_transform().transform(probe)
|
|
836
|
+
data_t = self.ax.transData.transform(probe)
|
|
837
|
+
if not np.allclose(off_t, data_t):
|
|
838
|
+
raise _BridgeError(
|
|
839
|
+
"non-data transforms are not supported")
|
|
840
|
+
except _BridgeError:
|
|
841
|
+
raise
|
|
842
|
+
except Exception:
|
|
843
|
+
pass
|
|
844
|
+
if coll.get_array() is not None:
|
|
845
|
+
raise _BridgeError("colormapped c= scatter values are not "
|
|
846
|
+
"supported (use a uniform colour)")
|
|
847
|
+
offsets = np.asarray(coll.get_offsets(), dtype=float)
|
|
848
|
+
if offsets.ndim != 2 or offsets.shape[1] != 2 \
|
|
849
|
+
or offsets.shape[0] == 0:
|
|
850
|
+
raise _BridgeError("only 1-D scatter offsets are supported")
|
|
851
|
+
x, y = _finite_pairs(offsets[:, 0], offsets[:, 1])
|
|
852
|
+
sizes = np.asarray(coll.get_sizes(), dtype=float)
|
|
853
|
+
if sizes.size != 1 and not np.all(sizes == sizes[0]):
|
|
854
|
+
raise _BridgeError("varying s= scatter sizes are not "
|
|
855
|
+
"supported")
|
|
856
|
+
s_pt2 = float(sizes[0]) if sizes.size else 36.0
|
|
857
|
+
faces = coll.get_facecolors()
|
|
858
|
+
if len(faces) != 1:
|
|
859
|
+
raise _BridgeError("multiple scatter colours are not "
|
|
860
|
+
"supported")
|
|
861
|
+
edges = coll.get_edgecolors()
|
|
862
|
+
try:
|
|
863
|
+
if len(edges) and any(tuple(e) != (0., 0., 0., 0.)
|
|
864
|
+
and _to_hex(e) != _to_hex(faces[0])
|
|
865
|
+
for e in edges):
|
|
866
|
+
self.note("scatter edge colours dropped")
|
|
867
|
+
except _BridgeError:
|
|
868
|
+
pass
|
|
869
|
+
path = coll.get_paths()[0]
|
|
870
|
+
marker = None
|
|
871
|
+
for m in MARKERS:
|
|
872
|
+
if not m:
|
|
873
|
+
continue
|
|
874
|
+
q = MarkerStyle(m).get_path().transformed(
|
|
875
|
+
MarkerStyle(m).get_transform())
|
|
876
|
+
if q.vertices.shape == path.vertices.shape \
|
|
877
|
+
and np.array_equal(path.vertices, q.vertices):
|
|
878
|
+
marker = m
|
|
879
|
+
break
|
|
880
|
+
if marker is None:
|
|
881
|
+
raise _BridgeError("scatter marker is not one of "
|
|
882
|
+
f"{tuple(m for m in MARKERS if m)}")
|
|
883
|
+
label = coll.get_label() or ''
|
|
884
|
+
if label.startswith('_'):
|
|
885
|
+
label = ''
|
|
886
|
+
s = LineSeries(
|
|
887
|
+
label=label, x=x, y=y, linestyle='', marker=marker,
|
|
888
|
+
markersize_pt=math.sqrt(s_pt2),
|
|
889
|
+
)
|
|
890
|
+
s.color = _to_hex(faces[0])
|
|
891
|
+
self.doc.series.append(s)
|
|
892
|
+
|
|
893
|
+
# ── bands (fill_between / stackplot) ─────────────────────────────
|
|
894
|
+
def _map_one_band(self, coll):
|
|
895
|
+
import numpy as np
|
|
896
|
+
if not coll.get_visible():
|
|
897
|
+
raise _BridgeError(
|
|
898
|
+
"hidden collections cannot be represented")
|
|
899
|
+
if getattr(coll, 't_direction', 'x') == 'y':
|
|
900
|
+
raise _BridgeError("fill_betweenx is not supported")
|
|
901
|
+
try:
|
|
902
|
+
probe = np.asarray([[0.1234, 0.5678]])
|
|
903
|
+
fill_t = coll.get_transform().transform(probe)
|
|
904
|
+
data_t = self.ax.transData.transform(probe)
|
|
905
|
+
if not np.allclose(fill_t, data_t):
|
|
906
|
+
raise _BridgeError(
|
|
907
|
+
"non-data transforms are not supported")
|
|
908
|
+
except _BridgeError:
|
|
909
|
+
raise
|
|
910
|
+
except Exception:
|
|
911
|
+
pass
|
|
912
|
+
if getattr(coll, 'get_hatch', lambda: None)() is not None:
|
|
913
|
+
raise _BridgeError("hatched bands are not supported")
|
|
914
|
+
faces = coll.get_facecolors()
|
|
915
|
+
if len(faces) != 1:
|
|
916
|
+
raise _BridgeError("multiple band colours are not "
|
|
917
|
+
"supported")
|
|
918
|
+
color = _to_hex(faces[0])
|
|
919
|
+
edges = coll.get_edgecolors()
|
|
920
|
+
try:
|
|
921
|
+
lws = [float(w) for w in coll.get_linewidths()]
|
|
922
|
+
if len(edges) and any(w > 0 for w in lws) \
|
|
923
|
+
and any(_to_hex(e) != color for e in edges):
|
|
924
|
+
self.note("band outlines dropped")
|
|
925
|
+
except _BridgeError:
|
|
926
|
+
pass
|
|
927
|
+
line_z = [l.get_zorder() for l in self.ax.get_lines()]
|
|
928
|
+
if line_z and coll.get_zorder() > max(line_z):
|
|
929
|
+
self.note("bands drawn under lines")
|
|
930
|
+
label = coll.get_label() or ''
|
|
931
|
+
if label.startswith('_'):
|
|
932
|
+
label = ''
|
|
933
|
+
for path in coll.get_paths():
|
|
934
|
+
x, y1, y2 = self._fill_path(path)
|
|
935
|
+
band = Band(label=label, x=x, y1=y1, y2=y2, color=color)
|
|
936
|
+
label = '' # only the first split segment keeps the label
|
|
937
|
+
self.doc.bands.append(band)
|
|
938
|
+
|
|
939
|
+
def _fill_path(self, path):
|
|
940
|
+
"""Decode a fill_between polygon into (x, y1, y2) or raise.
|
|
941
|
+
|
|
942
|
+
Layout (mpl 3.10): after dropping the trailing CLOSEPOLY
|
|
943
|
+
vertex the 2n+2 vertices are ``(x0,y2_0)``, the n forward
|
|
944
|
+
``(x,y1)`` points, ``(xn,y2_n)``, then the n reversed ``(x,y2)``
|
|
945
|
+
points.
|
|
946
|
+
"""
|
|
947
|
+
from matplotlib.path import Path as _MplPath
|
|
948
|
+
verts = [list(map(float, v)) for v in path.vertices]
|
|
949
|
+
codes = list(path.codes) if path.codes is not None else []
|
|
950
|
+
if codes and codes[-1] == _MplPath.CLOSEPOLY:
|
|
951
|
+
verts = verts[:-1]
|
|
952
|
+
m = len(verts)
|
|
953
|
+
if m < 6 or (m - 2) % 2:
|
|
954
|
+
raise _BridgeError("unrecognised fill polygon")
|
|
955
|
+
n = (m - 2) // 2
|
|
956
|
+
fwd = verts[1:n + 1]
|
|
957
|
+
rev = verts[n + 2:2 * n + 2]
|
|
958
|
+
flat = [v for pt in verts for v in pt]
|
|
959
|
+
if not all(map(math.isfinite, flat)):
|
|
960
|
+
raise _BridgeError("fill polygon has non-finite vertices")
|
|
961
|
+
x = [v[0] for v in fwd]
|
|
962
|
+
y1 = [v[1] for v in fwd]
|
|
963
|
+
y2 = [v[1] for v in rev][::-1]
|
|
964
|
+
x_rev = [v[0] for v in rev][::-1]
|
|
965
|
+
same = lambda a, b: _close(a, b)
|
|
966
|
+
if not (same(verts[0][0], x[0]) and same(verts[0][1], y2[0])
|
|
967
|
+
and same(verts[n + 1][0], x[-1])
|
|
968
|
+
and same(verts[n + 1][1], y2[-1])):
|
|
969
|
+
raise _BridgeError("unrecognised fill polygon")
|
|
970
|
+
if not all(same(a, b) for a, b in zip(x, x_rev)):
|
|
971
|
+
raise _BridgeError("unrecognised fill polygon")
|
|
972
|
+
return x, y1, y2
|
|
973
|
+
|
|
974
|
+
# ── bars ─────────────────────────────────────────────────────────
|
|
975
|
+
def _map_bars(self):
|
|
976
|
+
containers = self._bar_containers
|
|
977
|
+
if not containers:
|
|
978
|
+
return
|
|
979
|
+
doc = self.doc
|
|
980
|
+
if doc.series or doc.bands:
|
|
981
|
+
self.reason("bars cannot be mixed with line/scatter "
|
|
982
|
+
"series or bands")
|
|
983
|
+
return
|
|
984
|
+
doc.kind = 'stacked_column'
|
|
985
|
+
rects = []
|
|
986
|
+
for k, c in enumerate(containers):
|
|
987
|
+
ctx = f"bars[{k}]"
|
|
988
|
+
if getattr(c, 'orientation', 'vertical') != 'vertical':
|
|
989
|
+
self.reason(f"{ctx}: horizontal bars (barh) are not "
|
|
990
|
+
"supported")
|
|
991
|
+
continue
|
|
992
|
+
rs = list(c.patches)
|
|
993
|
+
xs = [float(p.get_x()) for p in rs]
|
|
994
|
+
ws = [float(p.get_width()) for p in rs]
|
|
995
|
+
bs = [float(p.get_y()) for p in rs]
|
|
996
|
+
hs = [float(p.get_height()) for p in rs]
|
|
997
|
+
if not rs:
|
|
998
|
+
self.reason(f"{ctx}: empty bar container")
|
|
999
|
+
continue
|
|
1000
|
+
if any(not all(map(math.isfinite, quad))
|
|
1001
|
+
for quad in zip(xs, ws, bs, hs)):
|
|
1002
|
+
self.reason(f"{ctx}: non-finite bar geometry")
|
|
1003
|
+
continue
|
|
1004
|
+
if any(h < 0 for h in hs):
|
|
1005
|
+
self.reason(f"{ctx}: negative bar heights are not "
|
|
1006
|
+
"supported")
|
|
1007
|
+
continue
|
|
1008
|
+
if len(set(round(w, 9) for w in ws)) != 1:
|
|
1009
|
+
self.reason(f"{ctx}: varying bar widths within one "
|
|
1010
|
+
"bar() call are not supported")
|
|
1011
|
+
continue
|
|
1012
|
+
colors = set()
|
|
1013
|
+
for p in rs:
|
|
1014
|
+
try:
|
|
1015
|
+
colors.add(_to_hex(p.get_facecolor()))
|
|
1016
|
+
except _BridgeError:
|
|
1017
|
+
colors.add(str(p.get_facecolor()))
|
|
1018
|
+
if len(colors) != 1:
|
|
1019
|
+
self.reason(f"{ctx}: per-bar colours are not "
|
|
1020
|
+
"supported")
|
|
1021
|
+
continue
|
|
1022
|
+
label = c.get_label() or ''
|
|
1023
|
+
if label.startswith('_'):
|
|
1024
|
+
label = ''
|
|
1025
|
+
rects.append(dict(c=c, ctx=ctx, xs=xs, ws=ws, bs=bs, hs=hs,
|
|
1026
|
+
color=colors.pop(), label=label,
|
|
1027
|
+
n=len(rs)))
|
|
1028
|
+
if self.reasons or not rects:
|
|
1029
|
+
return
|
|
1030
|
+
n = rects[0]['n']
|
|
1031
|
+
if any(r['n'] != n for r in rects):
|
|
1032
|
+
self.reason("bar groups with different bar counts are not "
|
|
1033
|
+
"supported")
|
|
1034
|
+
return
|
|
1035
|
+
layout = self._bar_layout(rects, n)
|
|
1036
|
+
if layout is None:
|
|
1037
|
+
return
|
|
1038
|
+
grouped, spacing, span, centres = layout
|
|
1039
|
+
if n > 1 and not self._categorical \
|
|
1040
|
+
and span / spacing >= 0.999:
|
|
1041
|
+
self.reason("histogram-style bars (touching bars on a "
|
|
1042
|
+
"numeric axis) are not supported")
|
|
1043
|
+
return
|
|
1044
|
+
if grouped:
|
|
1045
|
+
# Categories draw left→right in list order; flag sub-bars
|
|
1046
|
+
# that leave gaps or overlap inside a group.
|
|
1047
|
+
rects = sorted(rects, key=lambda r: r['xs'][0])
|
|
1048
|
+
edges = [(r['xs'][0], r['xs'][0] + r['ws'][0])
|
|
1049
|
+
for r in rects]
|
|
1050
|
+
tiled = all(_close(edges[i][1], edges[i + 1][0])
|
|
1051
|
+
for i in range(len(edges) - 1))
|
|
1052
|
+
if not tiled:
|
|
1053
|
+
self.note("grouped sub-bars have gaps or overlaps — "
|
|
1054
|
+
"the bar layout is normalised")
|
|
1055
|
+
doc.stacked = StackOptions(
|
|
1056
|
+
grouped=grouped, percent=False,
|
|
1057
|
+
bar_width=span / spacing)
|
|
1058
|
+
edges, ewidths = set(), set()
|
|
1059
|
+
for r in rects:
|
|
1060
|
+
for p in r['c'].patches:
|
|
1061
|
+
try:
|
|
1062
|
+
edges.add(_to_hex(p.get_edgecolor()))
|
|
1063
|
+
except _BridgeError:
|
|
1064
|
+
edges.add(str(p.get_edgecolor()))
|
|
1065
|
+
ewidths.add(round(float(p.get_linewidth()), 6))
|
|
1066
|
+
if len(edges) == 1 and len(ewidths) == 1:
|
|
1067
|
+
doc.stacked.edge_color = edges.pop()
|
|
1068
|
+
doc.stacked.edge_width_pt = ewidths.pop()
|
|
1069
|
+
for k, r in enumerate(rects):
|
|
1070
|
+
cat = StackCategory(label=r['label'] or f'Category {k + 1}',
|
|
1071
|
+
values=list(r['hs']), color=r['color'])
|
|
1072
|
+
yerr = self._bar_yerr.get(id(r['c']))
|
|
1073
|
+
if yerr is not None:
|
|
1074
|
+
lo, hi = yerr
|
|
1075
|
+
tops = [b + h for b, h in zip(r['bs'], r['hs'])]
|
|
1076
|
+
cat.yerr, cat.yerr_minus, cat.yerr_plus = \
|
|
1077
|
+
_yerr_fields(tops, lo, hi)
|
|
1078
|
+
if not grouped:
|
|
1079
|
+
self.note("error bars on stacked bars are ignored "
|
|
1080
|
+
"by the renderer")
|
|
1081
|
+
doc.categories.append(cat)
|
|
1082
|
+
doc.x_tick_labels = self._bar_tick_labels(centres)
|
|
1083
|
+
|
|
1084
|
+
def _bar_layout(self, rects, n):
|
|
1085
|
+
"""Detect single/grouped/stacked bar layout.
|
|
1086
|
+
|
|
1087
|
+
Returns (grouped, spacing, span, centres) or None (reason added).
|
|
1088
|
+
"""
|
|
1089
|
+
k = len(rects)
|
|
1090
|
+
if k > 1:
|
|
1091
|
+
same_x = all(
|
|
1092
|
+
all(_close(a, b) for a, b in zip(r['xs'], rects[0]['xs']))
|
|
1093
|
+
for r in rects[1:])
|
|
1094
|
+
same_w = all(
|
|
1095
|
+
all(_close(a, b) for a, b in zip(r['ws'], rects[0]['ws']))
|
|
1096
|
+
for r in rects[1:])
|
|
1097
|
+
if same_x and same_w:
|
|
1098
|
+
acc = [0.0] * n
|
|
1099
|
+
ok = True
|
|
1100
|
+
for r in rects:
|
|
1101
|
+
if not all(_close(bv, av) for bv, av
|
|
1102
|
+
in zip(r['bs'], acc)):
|
|
1103
|
+
ok = False
|
|
1104
|
+
break
|
|
1105
|
+
acc = [a + h for a, h in zip(acc, r['hs'])]
|
|
1106
|
+
if ok:
|
|
1107
|
+
r0 = rects[0]
|
|
1108
|
+
spacing = _spacing(r0['xs'], n)
|
|
1109
|
+
if spacing is None:
|
|
1110
|
+
self.reason("irregular bar positions are not "
|
|
1111
|
+
"supported")
|
|
1112
|
+
return None
|
|
1113
|
+
span = r0['ws'][0]
|
|
1114
|
+
centres = [x + span / 2.0 for x in r0['xs']]
|
|
1115
|
+
if not (0.0 < span / spacing <= 1.0):
|
|
1116
|
+
self.reason("bar width exceeds the group "
|
|
1117
|
+
"spacing")
|
|
1118
|
+
return None
|
|
1119
|
+
return False, spacing, span, centres
|
|
1120
|
+
self.reason("bar containers neither stack nor group "
|
|
1121
|
+
"(positions must match for stacking)")
|
|
1122
|
+
return None
|
|
1123
|
+
# grouped (also the single-container case)
|
|
1124
|
+
spacings = []
|
|
1125
|
+
for r in rects:
|
|
1126
|
+
s = _spacing(r['xs'], n)
|
|
1127
|
+
if s is None:
|
|
1128
|
+
self.reason(f"{r['ctx']}: irregular bar positions are "
|
|
1129
|
+
"not supported")
|
|
1130
|
+
return None
|
|
1131
|
+
spacings.append(s)
|
|
1132
|
+
if not all(_close(s, spacings[0]) for s in spacings):
|
|
1133
|
+
self.reason("bar groups with different spacings are not "
|
|
1134
|
+
"supported")
|
|
1135
|
+
return None
|
|
1136
|
+
spacing = spacings[0]
|
|
1137
|
+
left = min(r['xs'][0] for r in rects)
|
|
1138
|
+
right = max(r['xs'][0] + r['ws'][0] for r in rects)
|
|
1139
|
+
span = right - left
|
|
1140
|
+
if not (0.0 < span / spacing <= 1.0):
|
|
1141
|
+
self.reason("grouped bars overlap or leave no valid "
|
|
1142
|
+
"bar_width")
|
|
1143
|
+
return None
|
|
1144
|
+
centres = [left + span / 2.0 + i * spacing for i in range(n)]
|
|
1145
|
+
return True, spacing, span, centres
|
|
1146
|
+
|
|
1147
|
+
def _bar_tick_labels(self, centres):
|
|
1148
|
+
ax = self.ax
|
|
1149
|
+
if self._categorical:
|
|
1150
|
+
labels = self._category_labels()
|
|
1151
|
+
if labels is not None and len(labels) == len(centres):
|
|
1152
|
+
return [[float(i), labels[i]]
|
|
1153
|
+
for i in range(len(centres))]
|
|
1154
|
+
ticks = [(t.get_position()[0], t.get_text())
|
|
1155
|
+
for t in ax.get_xticklabels() if t.get_text()]
|
|
1156
|
+
if len(ticks) == len(centres):
|
|
1157
|
+
return [[float(i), lbl]
|
|
1158
|
+
for i, (_p, lbl) in enumerate(ticks)]
|
|
1159
|
+
fmt = ax.xaxis.get_major_formatter()
|
|
1160
|
+
locs = [float(v) for v in ax.xaxis.get_majorticklocs()]
|
|
1161
|
+
tick_labels = [t.get_text() for t in ax.get_xticklabels()]
|
|
1162
|
+
out = []
|
|
1163
|
+
for i, c in enumerate(centres):
|
|
1164
|
+
label = None
|
|
1165
|
+
for pos, text in zip(locs, tick_labels):
|
|
1166
|
+
if _close(pos, c):
|
|
1167
|
+
label = text or ''
|
|
1168
|
+
break
|
|
1169
|
+
if label is None:
|
|
1170
|
+
try:
|
|
1171
|
+
label = str(fmt(c, i))
|
|
1172
|
+
except Exception:
|
|
1173
|
+
label = ''
|
|
1174
|
+
if label == '':
|
|
1175
|
+
label = f'{c:g}'
|
|
1176
|
+
out.append([float(i), label])
|
|
1177
|
+
return out
|
|
1178
|
+
|
|
1179
|
+
# ── stray patches ────────────────────────────────────────────────
|
|
1180
|
+
def _map_patches(self):
|
|
1181
|
+
from matplotlib.artist import Artist
|
|
1182
|
+
owned = set()
|
|
1183
|
+
for c in self._bar_containers:
|
|
1184
|
+
owned.update(id(p) for p in c.patches)
|
|
1185
|
+
ax = self.ax
|
|
1186
|
+
seen = set()
|
|
1187
|
+
for p in ax.patches:
|
|
1188
|
+
if id(p) in owned:
|
|
1189
|
+
continue
|
|
1190
|
+
# axvspan/axhspan are Rectangles whose *artist* transform is
|
|
1191
|
+
# the blended x/y-axis transform (Patch.get_transform wraps
|
|
1192
|
+
# it in the patch transform, so ask Artist directly).
|
|
1193
|
+
artist_t = Artist.get_transform(p)
|
|
1194
|
+
axis = ('x' if artist_t == ax.get_xaxis_transform()
|
|
1195
|
+
else 'y' if artist_t == ax.get_yaxis_transform()
|
|
1196
|
+
else None)
|
|
1197
|
+
if axis is not None:
|
|
1198
|
+
try:
|
|
1199
|
+
self._map_one_span(p, axis)
|
|
1200
|
+
continue
|
|
1201
|
+
except _BridgeError as e:
|
|
1202
|
+
self.reason(f"{type(p).__name__}: {e}")
|
|
1203
|
+
continue
|
|
1204
|
+
name = type(p).__name__
|
|
1205
|
+
if name in seen:
|
|
1206
|
+
continue
|
|
1207
|
+
seen.add(name)
|
|
1208
|
+
self.reason(
|
|
1209
|
+
f"axes patches ({name} — axvspan/fill/shapes) are not "
|
|
1210
|
+
"supported")
|
|
1211
|
+
if self.doc.spans and self._bar_containers:
|
|
1212
|
+
self.reason("spans cannot be combined with bars")
|
|
1213
|
+
|
|
1214
|
+
def _map_one_span(self, p, axis):
|
|
1215
|
+
"""An axvspan/axhspan Rectangle → ``doc.spans`` or _BridgeError."""
|
|
1216
|
+
if not p.get_visible():
|
|
1217
|
+
raise _BridgeError(
|
|
1218
|
+
"hidden patches cannot be represented")
|
|
1219
|
+
if len(self.doc.spans) >= MAX_SPANS:
|
|
1220
|
+
raise _BridgeError(f"more than {MAX_SPANS} spans")
|
|
1221
|
+
verts = p.get_patch_transform().transform(p.get_path().vertices)
|
|
1222
|
+
xs = [float(v[0]) for v in verts]
|
|
1223
|
+
ys = [float(v[1]) for v in verts]
|
|
1224
|
+
lo = min(xs if axis == 'x' else ys)
|
|
1225
|
+
hi = max(xs if axis == 'x' else ys)
|
|
1226
|
+
cross = ys if axis == 'x' else xs
|
|
1227
|
+
# A document Span always covers the full cross axis; anything
|
|
1228
|
+
# partial (axvspan ymin/ymax, axhspan xmin/xmax) is a reason.
|
|
1229
|
+
if not (math.isfinite(lo) and math.isfinite(hi)) \
|
|
1230
|
+
or not lo < hi:
|
|
1231
|
+
raise _BridgeError("non-finite or empty span extent")
|
|
1232
|
+
if not (_close(min(cross), 0.0) and _close(max(cross), 1.0)):
|
|
1233
|
+
raise _BridgeError(
|
|
1234
|
+
"partial-height/width spans are not supported")
|
|
1235
|
+
color = _to_hex(p.get_facecolor())
|
|
1236
|
+
try:
|
|
1237
|
+
edge = p.get_edgecolor()
|
|
1238
|
+
if float(p.get_linewidth() or 0) > 0 \
|
|
1239
|
+
and _to_hex(edge) != color:
|
|
1240
|
+
self.note("span outlines dropped")
|
|
1241
|
+
except _BridgeError:
|
|
1242
|
+
self.note("span outlines dropped")
|
|
1243
|
+
label = p.get_label() or ''
|
|
1244
|
+
if label.startswith('_'):
|
|
1245
|
+
label = ''
|
|
1246
|
+
self.doc.spans.append(
|
|
1247
|
+
Span(axis=axis, lo=lo, hi=hi, color=color, label=label))
|
|
1248
|
+
|
|
1249
|
+
def _category_labels(self):
|
|
1250
|
+
units = getattr(self.ax.xaxis, 'units', None)
|
|
1251
|
+
mapping = getattr(units, '_mapping', None)
|
|
1252
|
+
if not mapping:
|
|
1253
|
+
return None
|
|
1254
|
+
try:
|
|
1255
|
+
ordered = sorted(mapping.items(), key=lambda kv: kv[1])
|
|
1256
|
+
return [str(k) for k, _v in ordered]
|
|
1257
|
+
except Exception:
|
|
1258
|
+
return None
|
|
1259
|
+
|
|
1260
|
+
# ── annotations / legend / limits ────────────────────────────────
|
|
1261
|
+
def _map_texts(self):
|
|
1262
|
+
ax, doc = self.ax, self.doc
|
|
1263
|
+
texts = list(ax.texts)
|
|
1264
|
+
if len(texts) > MAX_ANNOTATIONS:
|
|
1265
|
+
self.reason(f"more than {MAX_ANNOTATIONS} text annotations")
|
|
1266
|
+
return
|
|
1267
|
+
for i, t in enumerate(texts):
|
|
1268
|
+
ctx = f"text[{i}]"
|
|
1269
|
+
if getattr(t, 'arrowprops', None):
|
|
1270
|
+
self.reason(f"{ctx}: annotate() arrows are not "
|
|
1271
|
+
"supported")
|
|
1272
|
+
continue
|
|
1273
|
+
rot = t.get_rotation()
|
|
1274
|
+
if isinstance(rot, str):
|
|
1275
|
+
rot = {'horizontal': 0.0, 'vertical': 90.0}.get(rot, 0.0)
|
|
1276
|
+
if float(rot) != 0.0:
|
|
1277
|
+
self.reason(f"{ctx}: rotated text is not supported")
|
|
1278
|
+
continue
|
|
1279
|
+
try:
|
|
1280
|
+
bb = t.get_window_extent()
|
|
1281
|
+
centre = ax.transAxes.inverted().transform(
|
|
1282
|
+
bb.corners().mean(axis=0))
|
|
1283
|
+
fx, fy = float(centre[0]), float(centre[1])
|
|
1284
|
+
except Exception as e:
|
|
1285
|
+
self.reason(f"{ctx}: cannot measure the text extent "
|
|
1286
|
+
f"({e})")
|
|
1287
|
+
continue
|
|
1288
|
+
if not (-0.5 <= fx <= 1.5 and -0.5 <= fy <= 1.5):
|
|
1289
|
+
self.reason(f"{ctx}: text centre lies outside "
|
|
1290
|
+
"-0.5..1.5 axes fraction")
|
|
1291
|
+
continue
|
|
1292
|
+
a = Annotation(text=t.get_text() or ' ', x=fx, y=fy,
|
|
1293
|
+
box=t.get_bbox_patch() is not None)
|
|
1294
|
+
st = _text_style(t, self._base_size, self._base_family)
|
|
1295
|
+
if st.to_dict():
|
|
1296
|
+
a.style = st
|
|
1297
|
+
doc.annotations.append(a)
|
|
1298
|
+
|
|
1299
|
+
def _map_legend(self):
|
|
1300
|
+
ax, doc = self.ax, self.doc
|
|
1301
|
+
leg = ax.get_legend()
|
|
1302
|
+
if leg is None:
|
|
1303
|
+
return
|
|
1304
|
+
doc.legend = True
|
|
1305
|
+
if leg._loc not in _LEGEND_LOC_CODES:
|
|
1306
|
+
self.reason(f"legend location {leg._loc!r} is not "
|
|
1307
|
+
"supported (custom anchored/tuple locations "
|
|
1308
|
+
"cannot be represented)")
|
|
1309
|
+
else:
|
|
1310
|
+
doc.legend_location = _LEGEND_LOC_CODES[leg._loc]
|
|
1311
|
+
if getattr(leg, '_bbox_to_anchor', None) is not None:
|
|
1312
|
+
self.reason("legend bbox_to_anchor is not supported")
|
|
1313
|
+
handles = list(getattr(leg, 'legend_handles', [])
|
|
1314
|
+
or getattr(leg, 'legendHandles', []))
|
|
1315
|
+
expected = [s.label for s in doc.series if s.label] + \
|
|
1316
|
+
[c.label for c in doc.categories] + \
|
|
1317
|
+
[b.label for b in doc.bands if b.label] + \
|
|
1318
|
+
[sp.label for sp in doc.spans if sp.label]
|
|
1319
|
+
labelled = [h for h in handles
|
|
1320
|
+
if not str(h.get_label()).startswith('_')]
|
|
1321
|
+
if not all(h.get_label() in expected for h in labelled):
|
|
1322
|
+
self.reason("custom legend handles are not supported")
|
|
1323
|
+
texts = [t.get_text() for t in leg.get_texts()]
|
|
1324
|
+
if sorted(texts) != sorted(expected):
|
|
1325
|
+
self.reason("legend labels do not match the plotted "
|
|
1326
|
+
"series/categories")
|
|
1327
|
+
elif texts != expected:
|
|
1328
|
+
self.note("legend order normalised")
|
|
1329
|
+
ls = LegendStyle()
|
|
1330
|
+
ls.frame = bool(leg.get_frame_on())
|
|
1331
|
+
ls.ncols = int(getattr(leg, '_ncols',
|
|
1332
|
+
getattr(leg, '_ncol', 1)) or 1)
|
|
1333
|
+
try:
|
|
1334
|
+
ls.frame_color = _to_hex(leg.get_frame().get_edgecolor())
|
|
1335
|
+
except _BridgeError:
|
|
1336
|
+
pass
|
|
1337
|
+
if ls.to_dict():
|
|
1338
|
+
doc.style.legend = ls
|
|
1339
|
+
|
|
1340
|
+
def _map_limits(self):
|
|
1341
|
+
doc = self.doc
|
|
1342
|
+
n_series = len(doc.series) + len(doc.categories)
|
|
1343
|
+
if n_series > MAX_SERIES:
|
|
1344
|
+
self.reason(f"more than {MAX_SERIES} series/categories")
|
|
1345
|
+
total = sum(len(s.x) for s in doc.series) + \
|
|
1346
|
+
sum(len(c.values) for c in doc.categories) + \
|
|
1347
|
+
sum(len(b.x) for b in doc.bands)
|
|
1348
|
+
if total > MAX_TOTAL_POINTS:
|
|
1349
|
+
self.reason(f"more than {MAX_TOTAL_POINTS} data points")
|
|
1350
|
+
if doc.bands and not doc.series:
|
|
1351
|
+
self.reason("bands need at least one line or scatter "
|
|
1352
|
+
"series (band-only figures such as a bare "
|
|
1353
|
+
"stackplot are not supported)")
|
|
1354
|
+
if not doc.series and not doc.categories and not doc.bands:
|
|
1355
|
+
self.reason("the figure has no supported data to capture")
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _close(a, b, tol=1e-9):
|
|
1359
|
+
return math.isclose(a, b, rel_tol=0,
|
|
1360
|
+
abs_tol=tol * max(1.0, abs(a), abs(b)))
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _spacing(xs, n):
|
|
1364
|
+
"""Uniform spacing between consecutive bar positions, or None."""
|
|
1365
|
+
if n < 2:
|
|
1366
|
+
return 1.0
|
|
1367
|
+
diffs = [xs[i + 1] - xs[i] for i in range(n - 1)]
|
|
1368
|
+
if any(d <= 0 for d in diffs):
|
|
1369
|
+
return None
|
|
1370
|
+
if not all(_close(d, diffs[0]) for d in diffs):
|
|
1371
|
+
return None
|
|
1372
|
+
return diffs[0]
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _yerr_fields(y, lo, hi):
|
|
1376
|
+
"""Split measured lower/upper bounds into yerr fields."""
|
|
1377
|
+
minus = [max(0.0, yv - lv) for yv, lv in zip(y, lo)]
|
|
1378
|
+
plus = [max(0.0, hv - yv) for hv, yv in zip(hi, y)]
|
|
1379
|
+
symmetric = all(
|
|
1380
|
+
math.isclose(m, p, rel_tol=_REL_TOL,
|
|
1381
|
+
abs_tol=_REL_TOL * max(1.0, abs(p)))
|
|
1382
|
+
for m, p in zip(minus, plus))
|
|
1383
|
+
if symmetric:
|
|
1384
|
+
return list(plus), None, None
|
|
1385
|
+
return None, minus, plus
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def convert(figure) -> ConversionResult:
|
|
1389
|
+
"""Map *figure* onto a :class:`PlotDocument`.
|
|
1390
|
+
|
|
1391
|
+
Never raises for unsupported features: they are collected in
|
|
1392
|
+
``result.reasons`` (``result.document`` is then ``None``), while
|
|
1393
|
+
harmless normalisations land in ``result.notes``.
|
|
1394
|
+
"""
|
|
1395
|
+
return _Converter(figure).run()
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def savefig(figure, path, *, strict=False, **savefig_kwargs) -> SaveResult:
|
|
1399
|
+
"""Write *figure* to *path* as ``*.ilmplot.svg`` when representable.
|
|
1400
|
+
|
|
1401
|
+
When the figure uses unsupported features: ``strict=True`` raises
|
|
1402
|
+
:class:`UnsupportedFigureError` without touching *path*; otherwise a
|
|
1403
|
+
plain SVG (text kept as text) is written atomically to the same path
|
|
1404
|
+
and a :class:`FallbackWarning` listing the reasons is emitted.
|
|
1405
|
+
``savefig_kwargs`` (``bbox_inches``, ``transparent``, …) apply only to
|
|
1406
|
+
the fallback path.
|
|
1407
|
+
"""
|
|
1408
|
+
result = convert(figure)
|
|
1409
|
+
if not result.reasons:
|
|
1410
|
+
from .render import save_document
|
|
1411
|
+
save_document(result.document, path)
|
|
1412
|
+
return SaveResult(path=str(path), native=True, reasons=[],
|
|
1413
|
+
notes=result.notes)
|
|
1414
|
+
if strict:
|
|
1415
|
+
raise UnsupportedFigureError(
|
|
1416
|
+
'; '.join(result.reasons), reasons=result.reasons)
|
|
1417
|
+
warnings.warn(
|
|
1418
|
+
FallbackWarning(
|
|
1419
|
+
'figure uses features ilmplot cannot represent ('
|
|
1420
|
+
+ '; '.join(result.reasons)
|
|
1421
|
+
+ '); wrote a plain SVG instead'),
|
|
1422
|
+
stacklevel=2)
|
|
1423
|
+
import matplotlib
|
|
1424
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
1425
|
+
os.makedirs(parent, exist_ok=True)
|
|
1426
|
+
fd, tmp = tempfile.mkstemp(dir=parent,
|
|
1427
|
+
prefix='.' + os.path.basename(path),
|
|
1428
|
+
suffix='.tmp')
|
|
1429
|
+
os.close(fd)
|
|
1430
|
+
try:
|
|
1431
|
+
with matplotlib.rc_context({'svg.fonttype': 'none'}):
|
|
1432
|
+
figure.savefig(tmp, format='svg', **savefig_kwargs)
|
|
1433
|
+
os.replace(tmp, path)
|
|
1434
|
+
except BaseException:
|
|
1435
|
+
try:
|
|
1436
|
+
os.unlink(tmp)
|
|
1437
|
+
except OSError:
|
|
1438
|
+
pass
|
|
1439
|
+
raise
|
|
1440
|
+
return SaveResult(path=str(path), native=False,
|
|
1441
|
+
reasons=list(result.reasons),
|
|
1442
|
+
notes=list(result.notes))
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def document_from_figure(figure) -> PlotDocument:
|
|
1446
|
+
"""Convert *figure* into a :class:`PlotDocument` or raise.
|
|
1447
|
+
|
|
1448
|
+
Legacy strict API: the exception message is the collected reasons.
|
|
1449
|
+
"""
|
|
1450
|
+
result = convert(figure)
|
|
1451
|
+
if result.reasons:
|
|
1452
|
+
raise UnsupportedFigureError(
|
|
1453
|
+
'; '.join(result.reasons), reasons=result.reasons)
|
|
1454
|
+
return result.document
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def export_figure(figure, path: str) -> PlotDocument:
|
|
1458
|
+
"""Strictly convert *figure* and write the ``*.ilmplot.svg``.
|
|
1459
|
+
|
|
1460
|
+
Raises :class:`UnsupportedFigureError` before anything is written —
|
|
1461
|
+
the existing file at *path* is never touched on failure.
|
|
1462
|
+
"""
|
|
1463
|
+
from .render import save_document
|
|
1464
|
+
doc = document_from_figure(figure)
|
|
1465
|
+
save_document(doc, path)
|
|
1466
|
+
return doc
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
_GRID_LINESTYLES_OK = set(LINESTYLES) - {''}
|