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/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) - {''}